Changes
561 changed files (+5/-42413)
-
-
@@ -18,12 +18,6 @@ [*.md]# Markdown では末尾スペース 2 つが改行として扱われる。 trim_trailing_whitespace = true # Protocol Buffers の公式スタイルガイドに準拠。 # <https://protobuf.dev/programming-guides/style/> [*.proto] indent_style = space indent_size = 2 # YAML は 2 スペース以外のインデントを仕様レベルで読むことができない。 [*.{yml,yaml}] indent_style = space
-
-
.gitattributes (deleted)
-
@@ -1,8 +0,0 @@# プロジェクト固有のファイル別属性。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only # draw.io で生成された SVG は Base64 画像やダイアグラム情報などを含んでおり # 非常に長く可読性がほぼゼロに近いため diff を無効化している /docs/assets/architecture.svg -diff
-
-
-
@@ -6,6 +6,3 @@ # SPDX-License-Identifier: AGPL-3.0-onlydprint 0.47.5 bun 1.1.45 terraform 1.10.3 protoc-gen-connect-go 1.18.0 protoc-gen-go 1.36.5 go 1.24.2
-
-
LICENSES/Apache-2.0.txt (deleted)
-
@@ -1,73 +0,0 @@Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
-
-
LICENSES/BSD-3-Clause.txt (deleted)
-
@@ -1,11 +0,0 @@Copyright (c) <year> <owner>. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-
-
@@ -7,20 +7,3 @@ # 勤怠管理ソフトウェア日本の労働基準法に基づいた勤怠管理を補助するソフトウェア。 プロジェクトのコードネームは "yamori" (適当に思いついた、かつタイプしやすい名前という理由)。 ## 機能 ### 必須機能 (MVP) - [x] 有給休暇取得記録 - [ ] 有給休暇取得状況計算 (残数、法定取得義務日数) - [x] 労働者一覧・管理 ### 基本機能 - [x] 出勤記録 (休暇・欠勤含む) - [ ] 出勤状況計算 (出勤率、有給休暇付与判定) ## ドキュメント - [アーキテクチャ](./docs/ARCHITECTURE.md)
-
-
REUSE.toml (deleted)
-
@@ -1,19 +0,0 @@# SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only version = 1 [[annotations]] path = "packages/assets/*" SPDX-FileCopyrightText = "2024 Shota FUJI <pockawoooh@gmail.com>" SPDX-License-Identifier = "AGPL-3.0-only" [[annotations]] path = ["packages/**/go.sum", "go.work.sum"] SPDX-FileCopyrightText = "2025 Shota FUJI <pockawoooh@gmail.com>" SPDX-License-Identifier = "AGPL-3.0-only" [[annotations]] path = ["vendor/go-sqlite3-js/**/*"] SPDX-FileCopyrightText = "Copyright 2020 The Matrix.org Foundation C.I.C." SPDX-License-Identifier = "Apache-2.0"
-
-
-
@@ -4,926 +4,75 @@ "workspaces": {"": { "name": "@yamori/workspace", "devDependencies": { "@bufbuild/buf": "^1.47.2", "wireit": "^0.14.9", }, }, "packages/backend": { "name": "@yamori/backend", "dependencies": { "sql.js": "~1.6.2", }, }, "packages/idb_backend": { "name": "@yamori/idb_backend", "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "idb": "^8.0.0", }, "devDependencies": { "@types/bun": "^1.1.14", "fake-indexeddb": "^6.0.0", "typescript": "^5.7.2", }, }, "packages/proto": { "name": "@yamori/proto", "devDependencies": { "@bufbuild/protobuf": "^2.2.2", "@bufbuild/protoc-gen-es": "^2.2.2", }, "peerDependencies": { "@bufbuild/protobuf": "^2.2.0", }, }, "packages/pwa": { "name": "@yamori/pwa", "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@yamori/backend": "packages/backend", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "@yamori/react_ui": "packages/react_ui", "react": "^19.0.0", "react-dom": "^19.0.0", }, "devDependencies": { "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "rollup-plugin-license": "^3.5.3", "typescript": "^5.7.2", "vite": "^6.0.2", "zod": "^3.24.1", }, }, "packages/react_ui": { "name": "@yamori/react_ui", "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0", }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "@yamori/backend": "packages/backend", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2", }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x", }, }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.1", "", {}, "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], "@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], "@babel/compat-data": ["@babel/compat-data@7.26.3", "", {}, "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g=="], "@babel/core": ["@babel/core@7.26.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", "@babel/generator": "^7.26.0", "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.0", "@babel/parser": "^7.26.0", "@babel/template": "^7.25.9", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg=="], "@babel/generator": ["@babel/generator@7.26.3", "", { "dependencies": { "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.25.9", "", { "dependencies": { "@babel/compat-data": "^7.25.9", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="], "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.25.9", "", {}, "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="], "@babel/helpers": ["@babel/helpers@7.26.0", "", { "dependencies": { "@babel/template": "^7.25.9", "@babel/types": "^7.26.0" } }, "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw=="], "@babel/parser": ["@babel/parser@7.26.3", "", { "dependencies": { "@babel/types": "^7.26.3" }, "bin": "./bin/babel-parser.js" }, "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg=="], "@babel/runtime": ["@babel/runtime@7.26.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw=="], "@babel/template": ["@babel/template@7.25.9", "", { "dependencies": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg=="], "@babel/traverse": ["@babel/traverse@7.26.4", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.3", "@babel/parser": "^7.26.3", "@babel/template": "^7.25.9", "@babel/types": "^7.26.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w=="], "@babel/types": ["@babel/types@7.26.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA=="], "@bufbuild/buf": ["@bufbuild/buf@1.47.2", "", { "optionalDependencies": { "@bufbuild/buf-darwin-arm64": "1.47.2", "@bufbuild/buf-darwin-x64": "1.47.2", "@bufbuild/buf-linux-aarch64": "1.47.2", "@bufbuild/buf-linux-armv7": "1.47.2", "@bufbuild/buf-linux-x64": "1.47.2", "@bufbuild/buf-win32-arm64": "1.47.2", "@bufbuild/buf-win32-x64": "1.47.2" }, "bin": { "buf": "bin/buf", "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" } }, "sha512-glY5kCAoO4+a7HvDb+BLOdoHSdCk4mdXdkp53H8JFz7maOnkxCiHHXgRX+taFyEu25N8ybn7NjZFrZSdRwq2sA=="], "@bufbuild/buf-darwin-arm64": ["@bufbuild/buf-darwin-arm64@1.47.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-74WerFn06y+azgVfsnzhfbI5wla/OLPDnIvaNJBWHaqya/3bfascJkDylW2GVNHmwG1K/cscpmcc/RJPaO7ntQ=="], "@bufbuild/buf-darwin-x64": ["@bufbuild/buf-darwin-x64@1.47.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-adAiOacOQe8Ym/YXPCEiq9mrPeKRmDtF2TgqPWTcDy6mF7TqR7hMJINkEEuMd1EeACmXnzMOnXlm9ICtvdYgPg=="], "@bufbuild/buf-linux-aarch64": ["@bufbuild/buf-linux-aarch64@1.47.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-52vY+Owffr5diw2PyfQJqH+Fld6zW6NhNZak4zojvc2MjZKubWM0TfNyM9jXz2YrwyB+cyxkabE60nBI80m37w=="], "@bufbuild/buf-linux-armv7": ["@bufbuild/buf-linux-armv7@1.47.2", "", { "os": "linux", "cpu": "arm" }, "sha512-g9KtpObDeHZ/VG/0b5ZCieOao7L/WYZ0fPqFSs4N07D3APgEDhJG6vLyUcDgJMDgyLcgkNjNz0+XdYQb/tXyQw=="], "@bufbuild/buf-linux-x64": ["@bufbuild/buf-linux-x64@1.47.2", "", { "os": "linux", "cpu": "x64" }, "sha512-MODCK2BzD1Mgoyr+5Sp8xA8qMNdytj8hYheyhA5NnCGTkQf8sfqAjpBSAAmKk6Zar8HOlVXML6tzE/ioDFFGwQ=="], "@bufbuild/buf-win32-arm64": ["@bufbuild/buf-win32-arm64@1.47.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-563YKYWJl3LrCY3G3+zuhb8HwOs6DzWslwGPFkKV2hwHyWyvd1DR1JjiLvw9zX64IKNctQ0HempSqc3kcboaqQ=="], "@bufbuild/buf-win32-x64": ["@bufbuild/buf-win32-x64@1.47.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Sqcdv7La2xBDh3bTdEYb2f4UTMMqCcYe/D0RELhvQ5wDn6I35V3/2YT1OF5fRuf0BZLCo0OdO37S9L47uHSz2g=="], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.2.2", "", {}, "sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A=="], "@bufbuild/protoc-gen-es": ["@bufbuild/protoc-gen-es@2.2.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@bufbuild/protoplugin": "2.2.2" }, "bin": { "protoc-gen-es": "bin/protoc-gen-es" } }, "sha512-dQNfX2c6srAevuT0NR+C5OrNp+dlebIhR2R/GQOhJykCMJB1GB0jBnniSEWC+6YIUGFGLXgSyYbtNPaxZGPM0Q=="], "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.2.2", "", { "dependencies": { "@bufbuild/protobuf": "2.2.2", "@typescript/vfs": "^1.5.2", "typescript": "5.4.5" } }, "sha512-EKKrjBsA/F2l502PPWfmvx//qRJpOXpyDhwFOYSbwRYfqzEUuuNSb1A+YGE7iDxoEqxwo+len/CJny29iP1ESg=="], "@connectrpc/connect": ["@connectrpc/connect@2.0.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }, "sha512-xZuylIUNvNlH52e/4eQsZvY4QZyDJRtEFEDnn/yBrv5Xi5ZZI/p8X+GAHH35ucVaBvv9u7OzHZo8+tEh1EFTxA=="], "@connectrpc/connect-web": ["@connectrpc/connect-web@2.0.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0", "@connectrpc/connect": "2.0.2" } }, "sha512-QANMFPiL2o66BdBEctg4TsQLe5ozsBLqcle3dCBp7BwGlNGTY6NnNnqmt+YRnpeMW88GgomJwWNMGCrRD9pRKA=="], "@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.24.0", "", { "os": "android", "cpu": "arm" }, "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew=="], "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w=="], "@esbuild/android-x64": ["@esbuild/android-x64@0.24.0", "", { "os": "android", "cpu": "x64" }, "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ=="], "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw=="], "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA=="], "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA=="], "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ=="], "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw=="], "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g=="], "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA=="], "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g=="], "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA=="], "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ=="], "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw=="], "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g=="], "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA=="], "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.0", "", { "os": "none", "cpu": "x64" }, "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg=="], "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg=="], "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q=="], "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA=="], "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA=="], "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA=="], "@floating-ui/core": ["@floating-ui/core@1.6.8", "", { "dependencies": { "@floating-ui/utils": "^0.2.8" } }, "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA=="], "@floating-ui/dom": ["@floating-ui/dom@1.6.12", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.8" } }, "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w=="], "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.8", "", {}, "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig=="], "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.4.2", "", { "dependencies": { "magic-string": "^0.27.0", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-feQ+ntr+8hbVudnsTUapiMN9q8T90XA1d5jn9QzY09sNoj4iD9wi0PY1vsBFTda4ZjEaxRK9S81oarR2nj7TFQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.0", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="], "@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.1", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-DH8vuU7oqHt9RhO3V9Z1b8ek+bOl4+9VLsh0cgL6t7f2WhbuOChm3ft0EmCCsfd4ORi7Cs3II4aNcTXi+bh+wg=="], "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w=="], "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kNU4FIpcFMBLkOUcgeIteH06/8JLBcYY6Le1iKenDGCYNYFX3TQqCZjzkOsz37h7r94/99GTb7YhEr98ZBJibw=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.2", "", { "dependencies": { "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw=="], "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q=="], "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-menu": "2.1.4", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ap4wdGwK52rJxGkwukU1NrnEodsUFQIooANKu+ey7d6raQ2biTcEf8za1zr0mgFHieevRTB2nK4dJeN8pTAZGQ=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", "react-remove-scroll": "^2.6.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg=="], "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.4", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA=="], "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA=="], "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ=="], "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-roving-focus": "1.1.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.1.1", "react-remove-scroll": "^2.6.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A=="], "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IQWAsQ7dsLIYDrn0WqPU+cdM7MONTv9nqrLVYoie3BPiabSfUVDe6Fr+oEt0Cofsr9ONDcDe9xhmJbL1Uq1yKg=="], "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", "react-remove-scroll": "^2.6.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw=="], "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="], "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.1", "", { "dependencies": { "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA=="], "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.2.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-roving-focus": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E0MLLGfOP0l8P/NxgVzfXJ8w3Ch8cdO6UDzJfDChu4EJDy+/WdO5LqpdY8PYnCErkmZH3gZhDL1K7kQ41fAHuQ=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw=="], "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.2", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g=="], "@radix-ui/react-select": ["@radix-ui/react-select@2.1.4", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1", "aria-hidden": "^1.1.1", "react-remove-scroll": "^2.6.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ=="], "@radix-ui/react-slider": ["@radix-ui/react-slider@1.2.2", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="], "@radix-ui/react-switch": ["@radix-ui/react-switch@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g=="], "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-roving-focus": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ=="], "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Sch9idFJHJTMH9YNpxxESqABcAFweJG4tKv+0zo0m5XBvUSL8FM5xKcJLFLXononpePs8IclyX1KieL5SDUNgA=="], "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-i77tcgObYr743IonC1hrsnnPmszDRn8p+EGUsUt+5a/JFn28fxaM88Py6V2mc8J5kELMWishI0rLnuGLFD/nnQ=="], "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-roving-focus": "1.1.1", "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OgDLZEA30Ylyz8YSXvnGqIHtERqnUt1KUYTKdw/y8u7Ci6zGiJfXc02jahmcSNK3YcErqioj/9flWC9S1ihfwg=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.3", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw=="], "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.0", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="], "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="], "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg=="], "@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="], "@radix-ui/themes": ["@radix-ui/themes@3.1.6", "", { "dependencies": { "@radix-ui/colors": "^3.0.0", "@radix-ui/primitive": "^1.1.0", "@radix-ui/react-accessible-icon": "^1.1.0", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-compose-refs": "^1.1.0", "@radix-ui/react-context": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-direction": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.2.1", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-portal": "^1.1.2", "@radix-ui/react-primitive": "^2.0.0", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-roving-focus": "^1.1.0", "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-use-callback-ref": "^1.1.0", "@radix-ui/react-use-controllable-state": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.1.0", "classnames": "^2.3.2", "react-remove-scroll-bar": "^2.3.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4uaUK0E+3ZRURohKNqnzG8LciTJcpppuBbYxkp7miLyPiaXBwKTrEttdQpExsp/fP6J+ss+JHy5FJhU5lboQkg=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA=="], "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q=="], "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w=="], "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ=="], "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA=="], "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w=="], "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg=="], "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg=="], "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw=="], "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ=="], "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g=="], "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw=="], "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw=="], "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw=="], "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg=="], "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ=="], "@storybook/addon-actions": ["@storybook/addon-actions@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@types/uuid": "^9.0.1", "dequal": "^2.0.2", "polished": "^4.2.2", "uuid": "^9.0.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-mjtD5JxcPuW74T6h7nqMxWTvDneFtokg88p6kQ5OnC1M259iAXb//yiSZgu/quunMHPCXSiqn4FNOSgASTSbsA=="], "@storybook/addon-backgrounds": ["@storybook/addon-backgrounds@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "memoizerific": "^1.11.3", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-I4/aErqtFiazcoWyKafOAm3bLpxTj6eQuH/woSbk1Yx+EzN+Dbrgx1Updy8//bsNtKkcrXETITreqHC+a57DHQ=="], "@storybook/addon-controls": ["@storybook/addon-controls@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "dequal": "^2.0.2", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-377uo5IsJgXLnQLJixa47+11V+7Wn9KcDEw+96aGCBCfLbWNH8S08tJHHnSu+jXg9zoqCAC23MetntVp6LetHA=="], "@storybook/addon-docs": ["@storybook/addon-docs@8.4.7", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/blocks": "8.4.7", "@storybook/csf-plugin": "8.4.7", "@storybook/react-dom-shim": "8.4.7", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-NwWaiTDT5puCBSUOVuf6ME7Zsbwz7Y79WF5tMZBx/sLQ60vpmJVQsap6NSjvK1Ravhc21EsIXqemAcBjAWu80w=="], "@storybook/addon-essentials": ["@storybook/addon-essentials@8.4.7", "", { "dependencies": { "@storybook/addon-actions": "8.4.7", "@storybook/addon-backgrounds": "8.4.7", "@storybook/addon-controls": "8.4.7", "@storybook/addon-docs": "8.4.7", "@storybook/addon-highlight": "8.4.7", "@storybook/addon-measure": "8.4.7", "@storybook/addon-outline": "8.4.7", "@storybook/addon-toolbars": "8.4.7", "@storybook/addon-viewport": "8.4.7", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-+BtZHCBrYtQKILtejKxh0CDRGIgTl9PumfBOKRaihYb4FX1IjSAxoV/oo/IfEjlkF5f87vouShWsRa8EUauFDw=="], "@storybook/addon-highlight": ["@storybook/addon-highlight@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-whQIDBd3PfVwcUCrRXvCUHWClXe9mQ7XkTPCdPo4B/tZ6Z9c6zD8JUHT76ddyHivixFLowMnA8PxMU6kCMAiNw=="], "@storybook/addon-interactions": ["@storybook/addon-interactions@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/instrumenter": "8.4.7", "@storybook/test": "8.4.7", "polished": "^4.2.2", "ts-dedent": "^2.2.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-fnufT3ym8ht3HHUIRVXAH47iOJW/QOb0VSM+j269gDuvyDcY03D1civCu1v+eZLGaXPKJ8vtjr0L8zKQ/4P0JQ=="], "@storybook/addon-measure": ["@storybook/addon-measure@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "tiny-invariant": "^1.3.1" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-QfvqYWDSI5F68mKvafEmZic3SMiK7zZM8VA0kTXx55hF/+vx61Mm0HccApUT96xCXIgmwQwDvn9gS4TkX81Dmw=="], "@storybook/addon-outline": ["@storybook/addon-outline@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-6LYRqUZxSodmAIl8icr585Oi8pmzbZ90aloZJIpve+dBAzo7ydYrSQxxoQEVltXbKf3VeVcrs64ouAYqjisMYA=="], "@storybook/addon-toolbars": ["@storybook/addon-toolbars@8.4.7", "", { "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-OSfdv5UZs+NdGB+nZmbafGUWimiweJ/56gShlw8Neo/4jOJl1R3rnRqqY7MYx8E4GwoX+i3GF5C3iWFNQqlDcw=="], "@storybook/addon-viewport": ["@storybook/addon-viewport@8.4.7", "", { "dependencies": { "memoizerific": "^1.11.3" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-hvczh/jjuXXcOogih09a663sRDDSATXwbE866al1DXgbDFraYD/LxX/QDb38W9hdjU9+Qhx8VFIcNWoMQns5HQ=="], "@storybook/blocks": ["@storybook/blocks@8.4.7", "", { "dependencies": { "@storybook/csf": "^0.1.11", "@storybook/icons": "^1.2.12", "ts-dedent": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^8.4.7" }, "optionalPeers": ["react", "react-dom"] }, "sha512-+QH7+JwXXXIyP3fRCxz/7E2VZepAanXJM7G8nbR3wWsqWgrRp4Wra6MvybxAYCxU7aNfJX5c+RW84SNikFpcIA=="], "@storybook/builder-vite": ["@storybook/builder-vite@8.4.7", "", { "dependencies": { "@storybook/csf-plugin": "8.4.7", "browser-assert": "^1.2.1", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^8.4.7", "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" } }, "sha512-LovyXG5VM0w7CovI/k56ZZyWCveQFVDl0m7WwetpmMh2mmFJ+uPQ35BBsgTvTfc8RHi+9Q3F58qP1MQSByXi9g=="], "@storybook/components": ["@storybook/components@8.4.7", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-uyJIcoyeMWKAvjrG9tJBUCKxr2WZk+PomgrgrUwejkIfXMO76i6jw9BwLa0NZjYdlthDv30r9FfbYZyeNPmF0g=="], "@storybook/core": ["@storybook/core@8.4.7", "", { "dependencies": { "@storybook/csf": "^0.1.11", "better-opn": "^3.0.2", "browser-assert": "^1.2.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", "esbuild-register": "^3.5.0", "jsdoc-type-pratt-parser": "^4.0.0", "process": "^0.11.10", "recast": "^0.23.5", "semver": "^7.6.2", "util": "^0.12.5", "ws": "^8.2.3" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"] }, "sha512-7Z8Z0A+1YnhrrSXoKKwFFI4gnsLbWzr8fnDCU6+6HlDukFYh8GHRcZ9zKfqmy6U3hw2h8H5DrHsxWfyaYUUOoA=="], "@storybook/csf": ["@storybook/csf@0.1.12", "", { "dependencies": { "type-fest": "^2.19.0" } }, "sha512-9/exVhabisyIVL0VxTCxo01Tdm8wefIXKXfltAPTSr8cbLn5JAxGQ6QV3mjdecLGEOucfoVhAKtJfVHxEK1iqw=="], "@storybook/csf-plugin": ["@storybook/csf-plugin@8.4.7", "", { "dependencies": { "unplugin": "^1.3.1" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-Fgogplu4HImgC+AYDcdGm1rmL6OR1rVdNX1Be9C/NEXwOCpbbBwi0BxTf/2ZxHRk9fCeaPEcOdP5S8QHfltc1g=="], "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], "@storybook/icons": ["@storybook/icons@1.3.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, "sha512-Nz/UzeYQdUZUhacrPyfkiiysSjydyjgg/p0P9HxB4p/WaJUUjMAcaoaLgy3EXx61zZJ3iD36WPuDkZs5QYrA0A=="], "@storybook/instrumenter": ["@storybook/instrumenter@8.4.7", "", { "dependencies": { "@storybook/global": "^5.0.0", "@vitest/utils": "^2.1.1" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-k6NSD3jaRCCHAFtqXZ7tw8jAzD/yTEWXGya+REgZqq5RCkmJ+9S4Ytp/6OhQMPtPFX23gAuJJzTQVLcCr+gjRg=="], "@storybook/manager-api": ["@storybook/manager-api@8.4.7", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-ELqemTviCxAsZ5tqUz39sDmQkvhVAvAgiplYy9Uf15kO0SP2+HKsCMzlrm2ue2FfkUNyqbDayCPPCB0Cdn/mpQ=="], "@storybook/preview-api": ["@storybook/preview-api@8.4.7", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-0QVQwHw+OyZGHAJEXo6Knx+6/4er7n2rTDE5RYJ9F2E2Lg42E19pfdLlq2Jhoods2Xrclo3wj6GWR//Ahi39Eg=="], "@storybook/react": ["@storybook/react@8.4.7", "", { "dependencies": { "@storybook/components": "8.4.7", "@storybook/global": "^5.0.0", "@storybook/manager-api": "8.4.7", "@storybook/preview-api": "8.4.7", "@storybook/react-dom-shim": "8.4.7", "@storybook/theming": "8.4.7" }, "peerDependencies": { "@storybook/test": "8.4.7", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^8.4.7", "typescript": ">= 4.2.x" }, "optionalPeers": ["@storybook/test", "typescript"] }, "sha512-nQ0/7i2DkaCb7dy0NaT95llRVNYWQiPIVuhNfjr1mVhEP7XD090p0g7eqUmsx8vfdHh2BzWEo6CoBFRd3+EXxw=="], "@storybook/react-dom-shim": ["@storybook/react-dom-shim@8.4.7", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^8.4.7" } }, "sha512-6bkG2jvKTmWrmVzCgwpTxwIugd7Lu+2btsLAqhQSzDyIj2/uhMNp8xIMr/NBDtLgq3nomt9gefNa9xxLwk/OMg=="], "@storybook/react-vite": ["@storybook/react-vite@8.4.7", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "0.4.2", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "8.4.7", "@storybook/react": "8.4.7", "find-up": "^5.0.0", "magic-string": "^0.30.0", "react-docgen": "^7.0.0", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^8.4.7", "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" } }, "sha512-iiY9iLdMXhDnilCEVxU6vQsN72pW3miaf0WSenOZRyZv3HdbpgOxI0qapOS0KCyRUnX9vTlmrSPTMchY4cAeOg=="], "@storybook/test": ["@storybook/test@8.4.7", "", { "dependencies": { "@storybook/csf": "^0.1.11", "@storybook/global": "^5.0.0", "@storybook/instrumenter": "8.4.7", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/user-event": "14.5.2", "@vitest/expect": "2.0.5", "@vitest/spy": "2.0.5" }, "peerDependencies": { "storybook": "^8.4.7" } }, "sha512-AhvJsu5zl3uG40itSQVuSy5WByp3UVhS6xAnme4FWRwgSxhvZjATJ3AZkkHWOYjnnk+P2/sbz/XuPli1FVCWoQ=="], "@storybook/theming": ["@storybook/theming@8.4.7", "", { "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw=="], "@tanstack/query-core": ["@tanstack/query-core@5.62.8", "", {}, "sha512-4fV31vDsUyvNGrKIOUNPrZztoyL187bThnoQOvAXEVlZbSiuPONpfx53634MKKdvsDir5NyOGm80ShFaoHS/mw=="], "@tanstack/react-query": ["@tanstack/react-query@5.62.8", "", { "dependencies": { "@tanstack/query-core": "5.62.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-8TUstKxF/fysHonZsWg/hnlDVgasTdHx6Q+f1/s/oPKJBJbKUWPZEHwLTMOZgrZuroLMiqYKJ9w69Abm8mWP0Q=="], "@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.5.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "redent": "^3.0.0" } }, "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA=="], "@testing-library/user-event": ["@testing-library/user-event@14.5.2", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="], "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], "@types/bun": ["@types/bun@1.1.14", "", { "dependencies": { "bun-types": "1.1.37" } }, "sha512-opVYiFGtO2af0dnWBdZWlioLBoxSdDO5qokaazLhq8XQtGZbY4pY3/JxY8Zdf/hEwGubbp7ErZXoN1+h2yesxA=="], "@types/doctrine": ["@types/doctrine@0.0.9", "", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="], "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], "@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], "@types/react": ["@types/react@19.0.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg=="], "@types/react-dom": ["@types/react-dom@19.0.2", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg=="], "@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="], "@types/uuid": ["@types/uuid@9.0.8", "", {}, "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="], "@types/ws": ["@types/ws@8.5.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA=="], "@typescript/vfs": ["@typescript/vfs@1.6.0", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.3.4", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.14.2" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug=="], "@vitest/expect": ["@vitest/expect@2.0.5", "", { "dependencies": { "@vitest/spy": "2.0.5", "@vitest/utils": "2.0.5", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA=="], "@vitest/pretty-format": ["@vitest/pretty-format@2.1.8", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ=="], "@vitest/spy": ["@vitest/spy@2.0.5", "", { "dependencies": { "tinyspy": "^3.0.0" } }, "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA=="], "@vitest/utils": ["@vitest/utils@2.1.8", "", { "dependencies": { "@vitest/pretty-format": "2.1.8", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA=="], "@yamori/backend": ["@yamori/backend@workspace:packages/backend", { "dependencies": { "sql.js": "~1.6.2" } }], "@yamori/idb_backend": ["@yamori/idb_backend@workspace:packages/idb_backend", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "idb": "^8.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "fake-indexeddb": "^6.0.0", "typescript": "^5.7.2" } }], "@yamori/proto": ["@yamori/proto@workspace:packages/proto", { "devDependencies": { "@bufbuild/protobuf": "^2.2.2", "@bufbuild/protoc-gen-es": "^2.2.2" }, "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }], "@yamori/pwa": ["@yamori/pwa@workspace:packages/pwa", { "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@yamori/backend": "packages/backend", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "@yamori/react_ui": "packages/react_ui", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "rollup-plugin-license": "^3.5.3", "typescript": "^5.7.2", "vite": "^6.0.2", "zod": "^3.24.1" } }], "@yamori/react_ui": ["@yamori/react_ui@workspace:packages/react_ui", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "packages/idb_backend", "@yamori/proto": "packages/proto", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "@yamori/backend": "packages/backend", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x" } }], "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="], "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "array-find-index": ["array-find-index@1.0.2", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "balanced-match": ["balanced-match@3.0.1", "", {}, "sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w=="], "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "brace-expansion": ["brace-expansion@4.0.0", "", { "dependencies": { "balanced-match": "^3.0.0" } }, "sha512-l/mOwLWs7BQIgOKrL46dIAbyCKvPV7YJPDspkuc88rHsZRlg3hptUGdU7Trv0VFP4d3xnSGBQrKu5ZvGB7UeIw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browser-assert": ["browser-assert@1.2.1", "", {}, "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ=="], "browserslist": ["browserslist@4.24.3", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA=="], "bun-types": ["bun-types@1.1.37", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-C65lv6eBr3LPJWFZ2gswyrGZ82ljnH8flVE03xeXxKhi2ZGtFiO4isRKTKnitbSqtRAcaqYSR6djt1whI66AbA=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g=="], "call-bound": ["call-bound@1.0.3", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "get-intrinsic": "^1.2.6" } }, "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA=="], "caniuse-lite": ["caniuse-lite@1.0.30001690", "", {}, "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w=="], "chai": ["chai@5.1.2", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "commenting": ["commenting@1.1.0", "", {}, "sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.74", "", {}, "sha512-ck3//9RC+6oss/1Bh9tiAVFy5vfSKbRHAFh7Z3/eTRkEqJeWgymloShB17Vg3Z4nmDNp35vAd1BZ6CMW4Wt6Iw=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-object-atoms": ["es-object-atoms@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw=="], "esbuild": ["esbuild@0.24.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.0", "@esbuild/android-arm": "0.24.0", "@esbuild/android-arm64": "0.24.0", "@esbuild/android-x64": "0.24.0", "@esbuild/darwin-arm64": "0.24.0", "@esbuild/darwin-x64": "0.24.0", "@esbuild/freebsd-arm64": "0.24.0", "@esbuild/freebsd-x64": "0.24.0", "@esbuild/linux-arm": "0.24.0", "@esbuild/linux-arm64": "0.24.0", "@esbuild/linux-ia32": "0.24.0", "@esbuild/linux-loong64": "0.24.0", "@esbuild/linux-mips64el": "0.24.0", "@esbuild/linux-ppc64": "0.24.0", "@esbuild/linux-riscv64": "0.24.0", "@esbuild/linux-s390x": "0.24.0", "@esbuild/linux-x64": "0.24.0", "@esbuild/netbsd-x64": "0.24.0", "@esbuild/openbsd-arm64": "0.24.0", "@esbuild/openbsd-x64": "0.24.0", "@esbuild/sunos-x64": "0.24.0", "@esbuild/win32-arm64": "0.24.0", "@esbuild/win32-ia32": "0.24.0", "@esbuild/win32-x64": "0.24.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "fake-indexeddb": ["fake-indexeddb@6.0.0", "", {}, "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fastq": ["fastq@1.18.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw=="], "fdir": ["fdir@6.3.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-QOnuT+BOtivR77wYvCWHfGt9s4Pz1VIMbD463vegT5MLqNXy8rYFT/lPVEqf/bhYeT6qmqrNHhsX+rWwe3rOCQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "for-each": ["for-each@0.3.3", "", { "dependencies": { "is-callable": "^1.1.3" } }, "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-intrinsic": ["get-intrinsic@1.2.6", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "dunder-proto": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.0.0" } }, "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "idb": ["idb@8.0.0", "", {}, "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], "is-core-module": ["is-core-module@2.16.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-urTSINYfAYgcbLb0yDQ6egFm6h3Mo1DcF9EkyXSRjjzdHbsulg01qhwWuXdOoUBuTkbQ80KDboXa0vFJ+BDH+g=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-generator-function": ["is-generator-function@1.0.10", "", { "dependencies": { "has-tostringtag": "^1.0.0" } }, "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@4.1.0", "", {}, "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loupe": ["loupe@3.1.2", "", {}, "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.14", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-5c99P1WKTed11ZC0HMJOj6CDIue6F8ySu+bJL+85q1zBEIY8IklrJ1eiKC2NDRh3Ct3FcvmJPyQHb9erXMTJNw=="], "map-or-similar": ["map-or-similar@1.5.0", "", {}, "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "memoizerific": ["memoizerific@1.11.3", "", { "dependencies": { "map-or-similar": "^1.5.0" } }, "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "package-name-regex": ["package-name-regex@2.0.6", "", {}, "sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], "possible-typed-array-names": ["possible-typed-array-names@1.0.0", "", {}, "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q=="], "postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="], "react-docgen": ["react-docgen@7.1.0", "", { "dependencies": { "@babel/core": "^7.18.9", "@babel/traverse": "^7.18.9", "@babel/types": "^7.18.9", "@types/babel__core": "^7.18.0", "@types/babel__traverse": "^7.18.0", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-APPU8HB2uZnpl6Vt/+0AFoVYgSRtfiP6FLrZgPPTDmqSb2R4qZRbgd0A3VzIFxDt5e+Fozjx79WjLWnF69DK8g=="], "react-docgen-typescript": ["react-docgen-typescript@2.2.2", "", { "peerDependencies": { "typescript": ">= 4.3.x" } }, "sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg=="], "react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="], "react-hook-form": ["react-hook-form@7.54.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "react-remove-scroll": ["react-remove-scroll@2.6.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.1", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recast": ["recast@0.23.9", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q=="], "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], "resolve": ["resolve@1.22.9", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-QxrmX1DzraFIi9PxdG5VkRfRwIgjwyud+z/iBwfRRrVmHc+P9Q7u2lSSpQ6bjr2gy5lrqIiU9vb6iAeGf2400A=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], "rollup": ["rollup@4.28.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.28.0", "@rollup/rollup-android-arm64": "4.28.0", "@rollup/rollup-darwin-arm64": "4.28.0", "@rollup/rollup-darwin-x64": "4.28.0", "@rollup/rollup-freebsd-arm64": "4.28.0", "@rollup/rollup-freebsd-x64": "4.28.0", "@rollup/rollup-linux-arm-gnueabihf": "4.28.0", "@rollup/rollup-linux-arm-musleabihf": "4.28.0", "@rollup/rollup-linux-arm64-gnu": "4.28.0", "@rollup/rollup-linux-arm64-musl": "4.28.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.28.0", "@rollup/rollup-linux-riscv64-gnu": "4.28.0", "@rollup/rollup-linux-s390x-gnu": "4.28.0", "@rollup/rollup-linux-x64-gnu": "4.28.0", "@rollup/rollup-linux-x64-musl": "4.28.0", "@rollup/rollup-win32-arm64-msvc": "4.28.0", "@rollup/rollup-win32-ia32-msvc": "4.28.0", "@rollup/rollup-win32-x64-msvc": "4.28.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ=="], "rollup-plugin-license": ["rollup-plugin-license@3.5.3", "", { "dependencies": { "commenting": "~1.1.0", "fdir": "6.3.0", "lodash": "~4.17.21", "magic-string": "~0.30.0", "moment": "~2.30.1", "package-name-regex": "~2.0.6", "spdx-expression-validate": "~2.0.0", "spdx-satisfies": "~5.0.1" }, "peerDependencies": { "rollup": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" } }, "sha512-r3wImZSo2d6sEk9BRJtlzeI/upjyjnpthy06Fdl0EzqRrlg3ULb9KQR7xHJI0zuayW/8bchEXSF5dO6dha4OyA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "spdx-compare": ["spdx-compare@1.0.0", "", { "dependencies": { "array-find-index": "^1.0.2", "spdx-expression-parse": "^3.0.0", "spdx-ranges": "^2.0.0" } }, "sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A=="], "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], "spdx-expression-validate": ["spdx-expression-validate@2.0.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0" } }, "sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg=="], "spdx-license-ids": ["spdx-license-ids@3.0.20", "", {}, "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw=="], "spdx-ranges": ["spdx-ranges@2.1.1", "", {}, "sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA=="], "spdx-satisfies": ["spdx-satisfies@5.0.1", "", { "dependencies": { "spdx-compare": "^1.0.0", "spdx-expression-parse": "^3.0.0", "spdx-ranges": "^2.0.0" } }, "sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw=="], "sql.js": ["sql.js@1.6.2", "", {}, "sha512-9iucI5fXQa+Gspeqf/BNB20PxJIn5LhXDt4mjXoFPqXdR+NqtFs15SdKpSIJ6s529aGL9zFR9p2eSCIEiMsNGA=="], "storybook": ["storybook@8.4.7", "", { "dependencies": { "@storybook/core": "8.4.7" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": { "sb": "./bin/index.cjs", "storybook": "./bin/index.cjs", "getstorybook": "./bin/index.cjs" } }, "sha512-RP/nMJxiWyFc8EVMH5gp20ID032Wvk+Yr3lmKidoegto5Iy+2dVQnUoElZb2zpbVXNHWakGuAkfI0dY1Hfp/vw=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-indent": ["strip-indent@4.0.0", "", { "dependencies": { "min-indent": "^1.0.1" } }, "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], "typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="], "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "unplugin": ["unplugin@1.16.0", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5liCNPuJW8dqh3+DM6uNM2EI3MLLpCKp/KY+9pB5M2S2SR2qvvDHhKgBOaTWEbZTAws3CXfB0rKTIolWKL05VQ=="], "update-browserslist-db": ["update-browserslist-db@1.1.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.0" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A=="], "urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "vite": ["vite@6.0.2", "", { "dependencies": { "esbuild": "^0.24.0", "postcss": "^8.4.49", "rollup": "^4.23.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-XdQ+VsY2tJpBsKGs0wf3U/+azx8BBpYRHFAyKm5VeEZNOJZRB63q7Sc8Iup3k0TrN3KO6QgyzFf+opSbfY1y0g=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "which-typed-array": ["which-typed-array@1.1.18", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.3", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA=="], "wireit": ["wireit@0.14.9", "", { "dependencies": { "brace-expansion": "^4.0.0", "chokidar": "^3.5.3", "fast-glob": "^3.2.11", "jsonc-parser": "^3.0.0", "proper-lockfile": "^4.1.2" }, "bin": { "wireit": "bin/wireit.js" } }, "sha512-hFc96BgyslfO1WGSzQqOVYd5N3TB+4u9w70L9GHR/T7SYjvFmeznkYMsRIjMLhPcVabCEYPW1vV66wmIVDs+dQ=="], "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], "@joshwooding/vite-plugin-react-docgen-typescript/magic-string": ["magic-string@0.27.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.13" } }, "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA=="], "@storybook/addon-docs/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "@storybook/addon-docs/react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], "@storybook/core/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@vitest/expect/@vitest/utils": ["@vitest/utils@2.0.5", "", { "dependencies": { "@vitest/pretty-format": "2.0.5", "estree-walker": "^3.0.3", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@2.0.5", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ=="], "@vitest/expect/@vitest/utils/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], } }
-
-
docs/ARCHITECTURE.md (deleted)
-
@@ -1,11 +0,0 @@<!-- SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only --> # アーキテクチャ GUI は HTML+JS+CSS であり、 Protocol Buffers を使って環境ごとのバックエンドとデータ通信を行う。 実際のトランスポートは環境ごとに異なる。 
-
-
docs/DEVELOPMENT.md (deleted)
-
@@ -1,46 +0,0 @@<!-- SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only --> # 開発ガイド [bun]: https://bun.sh/ [dprint]: https://dprint.dev/ ## 前提環境 このプロジェクトを開発するにあたり以下のものが必要になる。 - [Bun][bun] v1.1.x - [dprint][dprint] v0.47.x - [reuse tool](https://github.com/fsfe/reuse-tool) v4 以上 `.tool-versions` に対応した [asdf](https://asdf-vm.com/) や [mise](https://mise.jdx.dev/) といったバージョン管理ツールの利用を強く推奨。 ## JavaScript パッケージのインストール [Bun][bun] の Workspace 機能を利用しているため、リポジトリルートで `bun i` を実行すれば各パッケージで必要な全ての依存パッケージがインストールされる。 ``` $ bun i ``` この作業は `package.json` や `bun.lockb` に変更があった際に毎回実行する必要がある。 ## ソースコードの自動整形 [dprint][dprint] を使ってソースコードの自動整形を統一して行えるようになっている。 ``` $ dprint fmt ``` ## 著作権とライセンス表記のチェック このプロジェクトは [REUSE v3.3](https://reuse.software/spec-3.3/) に準拠した著作権とライセンス表記を行っている。 以下のコマンドで全てのファイルに適切に著作権とライセンスの表記がされているか確認できる。 ``` $ reuse lint ```
-
-
docs/assets/architecture.svg (deleted)
-
@@ -1,4 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- Do not edit this file with editors other than draw.io --> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <svg xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent; color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="761px" height="577px" viewBox="-0.5 -0.5 761 577" content="<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" scale="1" border="0" version="26.1.0"> <diagram name="Page-1" id="OkK6BvIz9H3dHL7W3ds0"> <mxGraphModel dx="2440" dy="1359" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0"> <root> <mxCell id="0" /> <mxCell id="1" parent="0" /> <mxCell id="f4uEDvzRM5RpCF_URP4V-41" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=1;exitDx=0;exitDy=0;entryX=0;entryY=0;entryDx=0;entryDy=0;dashed=1;" edge="1" parent="1" source="Jth4lL2z7hU5_HQBh-La-2" target="f4uEDvzRM5RpCF_URP4V-40"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-42" value="Import" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-41"> <mxGeometry x="-0.4901" y="-1" relative="1" as="geometry"> <mxPoint x="20" y="10" as="offset" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-2" value="UI Components" style="rounded=1;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="80" y="105" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;startArrow=classic;startFill=1;endArrow=none;endFill=0;dashed=1;" parent="1" source="Jth4lL2z7hU5_HQBh-La-3" target="Jth4lL2z7hU5_HQBh-La-2" edge="1"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-9" value="Import" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="Jth4lL2z7hU5_HQBh-La-8" vertex="1" connectable="0"> <mxGeometry y="2" relative="1" as="geometry"> <mxPoint x="2" y="-3" as="offset" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;dashed=1;" parent="1" source="Jth4lL2z7hU5_HQBh-La-3" target="Jth4lL2z7hU5_HQBh-La-4" edge="1"> <mxGeometry relative="1" as="geometry"> <mxPoint x="140" y="400" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-13" value="Bundle" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="Jth4lL2z7hU5_HQBh-La-11" vertex="1" connectable="0"> <mxGeometry x="-0.1" y="-2" relative="1" as="geometry"> <mxPoint as="offset" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-3" value="&lt;div&gt;Desktop UI&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="80" y="200" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-5" value="Desktop App" style="swimlane;horizontal=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="30" y="304" width="290" height="276" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;startArrow=classic;startFill=1;" parent="Jth4lL2z7hU5_HQBh-La-5" source="Jth4lL2z7hU5_HQBh-La-4" target="Jth4lL2z7hU5_HQBh-La-17" edge="1"> <mxGeometry relative="1" as="geometry"> <mxPoint x="140" y="130" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-4" value="Desktop Bundle" style="rounded=0;whiteSpace=wrap;html=1;" parent="Jth4lL2z7hU5_HQBh-La-5" vertex="1"> <mxGeometry x="50" y="15" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;startArrow=classic;startFill=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="Jth4lL2z7hU5_HQBh-La-5" source="Jth4lL2z7hU5_HQBh-La-15" target="Jth4lL2z7hU5_HQBh-La-17" edge="1"> <mxGeometry relative="1" as="geometry"> <mxPoint x="80" y="170" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-15" value="&lt;div&gt;Desktop Backend&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;" parent="Jth4lL2z7hU5_HQBh-La-5" vertex="1"> <mxGeometry x="50" y="196" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-17" value="&lt;div&gt;Wails&lt;/div&gt;&lt;div&gt;(Go framework)&lt;/div&gt;" style="rounded=0;whiteSpace=wrap;html=1;" parent="Jth4lL2z7hU5_HQBh-La-5" vertex="1"> <mxGeometry x="50" y="106" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-36" value="&lt;div&gt;SQLite&lt;/div&gt;" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;" vertex="1" parent="Jth4lL2z7hU5_HQBh-La-5"> <mxGeometry x="200" y="186" width="70" height="80" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-37" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="Jth4lL2z7hU5_HQBh-La-5" source="Jth4lL2z7hU5_HQBh-La-15" target="f4uEDvzRM5RpCF_URP4V-36"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;endArrow=none;endFill=0;startArrow=classic;startFill=1;dashed=1;" parent="1" source="Jth4lL2z7hU5_HQBh-La-27" target="Jth4lL2z7hU5_HQBh-La-2" edge="1"> <mxGeometry relative="1" as="geometry"> <Array as="points" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-29" value="Import" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="Jth4lL2z7hU5_HQBh-La-28" vertex="1" connectable="0"> <mxGeometry x="-0.0481" relative="1" as="geometry"> <mxPoint x="-2" as="offset" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;dashed=1;" parent="1" source="Jth4lL2z7hU5_HQBh-La-27" target="Jth4lL2z7hU5_HQBh-La-30" edge="1"> <mxGeometry relative="1" as="geometry"> <mxPoint x="259.9999999999998" y="140" as="sourcePoint" /> <mxPoint x="419.9999999999998" y="55.000000000000114" as="targetPoint" /> <Array as="points"> <mxPoint x="340" y="60" /> <mxPoint x="550" y="60" /> </Array> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-32" value="Bundle" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="Jth4lL2z7hU5_HQBh-La-31" vertex="1" connectable="0"> <mxGeometry x="-0.125" y="-3" relative="1" as="geometry"> <mxPoint x="3" y="-3" as="offset" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-27" value="&lt;div&gt;PWA UI&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="280" y="105" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-47" value="PWA" style="swimlane;horizontal=0;whiteSpace=wrap;html=1;" parent="1" vertex="1"> <mxGeometry x="440" y="90" width="350" height="240" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-30" value="PWA Bundle" style="rounded=0;whiteSpace=wrap;html=1;" parent="Jth4lL2z7hU5_HQBh-La-47" vertex="1"> <mxGeometry x="50" y="15" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-18" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;startArrow=classic;startFill=1;exitX=0.75;exitY=1;exitDx=0;exitDy=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="Jth4lL2z7hU5_HQBh-La-47" source="Jth4lL2z7hU5_HQBh-La-35" target="f4uEDvzRM5RpCF_URP4V-16"> <mxGeometry relative="1" as="geometry"> <mxPoint x="170" y="220" as="sourcePoint" /> <mxPoint x="220" y="220" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-19" value="Message" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];rotation=0;" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-18"> <mxGeometry x="-0.0551" relative="1" as="geometry"> <mxPoint as="offset" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-35" value="Backend Worker" style="rounded=1;whiteSpace=wrap;html=1;" parent="Jth4lL2z7hU5_HQBh-La-47" vertex="1"> <mxGeometry x="50" y="140" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-36" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;startArrow=classic;startFill=1;" parent="Jth4lL2z7hU5_HQBh-La-47" source="Jth4lL2z7hU5_HQBh-La-30" target="Jth4lL2z7hU5_HQBh-La-35" edge="1"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-37" value="&lt;div&gt;Message&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="Jth4lL2z7hU5_HQBh-La-36" vertex="1" connectable="0"> <mxGeometry x="0.0316" y="1" relative="1" as="geometry"> <mxPoint as="offset" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-38" value="&lt;div&gt;IndexedDB&lt;/div&gt;&lt;div&gt;/ OPFS&lt;br&gt;&lt;/div&gt;" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;" parent="Jth4lL2z7hU5_HQBh-La-47" vertex="1"> <mxGeometry x="260" y="15" width="70" height="80" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-39" value="Write" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.25;exitY=0;exitDx=0;exitDy=0;entryX=0.078;entryY=0.893;entryDx=0;entryDy=0;entryPerimeter=0;" parent="Jth4lL2z7hU5_HQBh-La-47" source="f4uEDvzRM5RpCF_URP4V-16" target="Jth4lL2z7hU5_HQBh-La-38" edge="1"> <mxGeometry relative="1" as="geometry"> <mxPoint x="240" y="140" as="targetPoint" /> </mxGeometry> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-40" style="rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.855;exitY=1;exitDx=0;exitDy=-4.35;exitPerimeter=0;entryX=0.712;entryY=0;entryDx=0;entryDy=0;entryPerimeter=0;" parent="Jth4lL2z7hU5_HQBh-La-47" source="Jth4lL2z7hU5_HQBh-La-38" target="f4uEDvzRM5RpCF_URP4V-16" edge="1"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="Jth4lL2z7hU5_HQBh-La-41" value="Read" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="Jth4lL2z7hU5_HQBh-La-40" vertex="1" connectable="0"> <mxGeometry x="0.032" y="1" relative="1" as="geometry"> <mxPoint x="1" y="-1" as="offset" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-16" value="SQLite Worker" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="Jth4lL2z7hU5_HQBh-La-47"> <mxGeometry x="220" y="140" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-2" value="Web App" style="swimlane;horizontal=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="500" y="380" width="290" height="200" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.75;exitY=1;exitDx=0;exitDy=0;entryX=0.75;entryY=0;entryDx=0;entryDy=0;startArrow=classic;startFill=1;" edge="1" parent="f4uEDvzRM5RpCF_URP4V-2" source="f4uEDvzRM5RpCF_URP4V-5" target="f4uEDvzRM5RpCF_URP4V-10"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-28" value="RPC" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-27"> <mxGeometry x="-0.3407" relative="1" as="geometry"> <mxPoint y="10" as="offset" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-5" value="Web Bundle" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="f4uEDvzRM5RpCF_URP4V-2"> <mxGeometry x="50" y="15" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.25;exitY=0;exitDx=0;exitDy=0;entryX=0.25;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="f4uEDvzRM5RpCF_URP4V-2" source="f4uEDvzRM5RpCF_URP4V-10" target="f4uEDvzRM5RpCF_URP4V-5"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-7" value="Serve" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-6"> <mxGeometry x="-0.1111" y="-2" relative="1" as="geometry"> <mxPoint as="offset" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-10" value="&lt;div&gt;Web Backend&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="f4uEDvzRM5RpCF_URP4V-2"> <mxGeometry x="50" y="120" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-29" value="&lt;div&gt;PostgreSQL&lt;/div&gt;&lt;div&gt;/ SQLite&lt;/div&gt;" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;" vertex="1" parent="f4uEDvzRM5RpCF_URP4V-2"> <mxGeometry x="200" y="110" width="70" height="80" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-30" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="f4uEDvzRM5RpCF_URP4V-2" source="f4uEDvzRM5RpCF_URP4V-10" target="f4uEDvzRM5RpCF_URP4V-29"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-32" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.25;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;dashed=1;" edge="1" parent="1" source="f4uEDvzRM5RpCF_URP4V-31" target="Jth4lL2z7hU5_HQBh-La-15"> <mxGeometry relative="1" as="geometry"> <Array as="points"> <mxPoint x="384" y="620" /> <mxPoint x="140" y="620" /> </Array> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-33" value="Import" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-32"> <mxGeometry x="-0.2636" y="-2" relative="1" as="geometry"> <mxPoint x="-108" as="offset" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.75;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;dashed=1;" edge="1" parent="1" source="f4uEDvzRM5RpCF_URP4V-31" target="f4uEDvzRM5RpCF_URP4V-10"> <mxGeometry relative="1" as="geometry"> <Array as="points"> <mxPoint x="444" y="620" /> <mxPoint x="610" y="620" /> </Array> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-35" value="Import" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-34"> <mxGeometry x="-0.2275" relative="1" as="geometry"> <mxPoint x="86" y="11" as="offset" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.75;exitY=0;exitDx=0;exitDy=0;entryX=0.25;entryY=1;entryDx=0;entryDy=0;jumpStyle=none;dashed=1;" edge="1" parent="1" source="f4uEDvzRM5RpCF_URP4V-31" target="Jth4lL2z7hU5_HQBh-La-35"> <mxGeometry relative="1" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-39" value="Import" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-38"> <mxGeometry x="-0.2012" y="3" relative="1" as="geometry"> <mxPoint x="17" as="offset" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-31" value="Backend Core" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="354" y="400" width="120" height="60" as="geometry" /> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-43" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.75;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;jumpStyle=arc;jumpSize=10;" edge="1" parent="1" source="f4uEDvzRM5RpCF_URP4V-40" target="f4uEDvzRM5RpCF_URP4V-5"> <mxGeometry relative="1" as="geometry"> <Array as="points"> <mxPoint x="370" y="360" /> <mxPoint x="610" y="360" /> </Array> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-44" value="Bundle" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="f4uEDvzRM5RpCF_URP4V-43"> <mxGeometry x="0.3511" y="1" relative="1" as="geometry"> <mxPoint x="-154" y="-49" as="offset" /> </mxGeometry> </mxCell> <mxCell id="f4uEDvzRM5RpCF_URP4V-40" value="&lt;div&gt;Web UI&lt;/div&gt;" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> <mxGeometry x="280" y="200" width="120" height="60" as="geometry" /> </mxCell> </root> </mxGraphModel> </diagram> </mxfile> "><defs/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="f4uEDvzRM5RpCF_URP4V-41"><g><path d="M 170 112 L 244.17 144.45" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 248.98 146.55 L 241.16 146.95 L 244.17 144.45 L 243.97 140.54 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-42"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 132px; margin-left: 211px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Import</div></div></div></foreignObject><text x="211" y="136" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Import</text></switch></g></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-2"><g><rect x="50" y="52" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 82px; margin-left: 51px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">UI Components</div></div></div></foreignObject><text x="110" y="86" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">UI Components</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-8"><g><path d="M 110 140.63 L 110 112" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 110 145.88 L 106.5 138.88 L 110 140.63 L 113.5 138.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-9"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 127px; margin-left: 111px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Import</div></div></div></foreignObject><text x="111" y="130" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Import</text></switch></g></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-11"><g><path d="M 110 207 L 110 259.63" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 110 264.88 L 106.5 257.88 L 110 259.63 L 113.5 257.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-13"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 235px; margin-left: 109px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Bundle</div></div></div></foreignObject><text x="109" y="238" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Bundle</text></switch></g></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-3"><g><rect x="50" y="147" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 177px; margin-left: 51px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>Desktop UI</div></div></div></div></foreignObject><text x="110" y="181" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Desktop UI</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-5"><g><path d="M 23 251 L 0 251 L 0 527 L 23 527" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 23 251 L 290 251 L 290 527 L 23 527" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 23 251 L 23 527" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)rotate(-90 11.5 389)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 274px; height: 1px; padding-top: 389px; margin-left: -125px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; font-weight: bold; white-space: normal; word-wrap: normal; ">Desktop App</div></div></div></foreignObject><text x="12" y="393" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle" font-weight="bold">Desktop App</text></switch></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-22"><g><path d="M 110 332.37 L 110 350.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 110 327.12 L 113.5 334.12 L 110 332.37 L 106.5 334.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 110 355.88 L 106.5 348.88 L 110 350.63 L 113.5 348.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-4"><g><rect x="50" y="266" width="120" height="60" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 296px; margin-left: 51px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">Desktop Bundle</div></div></div></foreignObject><text x="110" y="300" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Desktop Bundle</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-19"><g><path d="M 110 440.63 L 110 423.37" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 110 445.88 L 106.5 438.88 L 110 440.63 L 113.5 438.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 110 418.12 L 113.5 425.12 L 110 423.37 L 106.5 425.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-15"><g><rect x="50" y="447" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 477px; margin-left: 51px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>Desktop Backend</div></div></div></div></foreignObject><text x="110" y="481" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Desktop Backend</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-17"><g><rect x="50" y="357" width="120" height="60" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 387px; margin-left: 51px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>Wails</div><div>(Go framework)</div></div></div></div></foreignObject><text x="110" y="391" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Wails...</text></switch></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-36"><g><path d="M 200 452 C 200 443.72 215.67 437 235 437 C 244.28 437 253.18 438.58 259.75 441.39 C 266.31 444.21 270 448.02 270 452 L 270 502 C 270 510.28 254.33 517 235 517 C 215.67 517 200 510.28 200 502 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 270 452 C 270 460.28 254.33 467 235 467 C 215.67 467 200 460.28 200 452" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 68px; height: 1px; padding-top: 490px; margin-left: 201px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>SQLite</div></div></div></div></foreignObject><text x="235" y="494" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">SQLite</text></switch></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-37"><g><path d="M 170 477 L 193.63 477" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 198.88 477 L 191.88 480.5 L 193.63 477 L 191.88 473.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-28"><g><path d="M 243.63 82 L 170 82" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 248.88 82 L 241.88 85.5 L 243.63 82 L 241.88 78.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-29"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 83px; margin-left: 211px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Import</div></div></div></foreignObject><text x="211" y="86" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Import</text></switch></g></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-31"><g><path d="M 310 52 L 310 6.94 L 520 6.94 L 520 45.63" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 520 50.88 L 516.5 43.88 L 520 45.63 L 523.5 43.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-32"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 8px; margin-left: 400px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Bundle</div></div></div></foreignObject><text x="400" y="11" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Bundle</text></switch></g></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-27"><g><rect x="250" y="52" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 82px; margin-left: 251px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>PWA UI</div></div></div></div></foreignObject><text x="310" y="86" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">PWA UI</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-47"><g><path d="M 433 37 L 410 37 L 410 277 L 433 277" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 433 37 L 760 37 L 760 277 L 433 277" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 433 37 L 433 277" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)rotate(-90 421.5 156.99999999999977)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 238px; height: 1px; padding-top: 157px; margin-left: 303px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; font-weight: bold; white-space: normal; word-wrap: normal; ">PWA</div></div></div></foreignObject><text x="422" y="161" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle" font-weight="bold">PWA</text></switch></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-30"><g><rect x="460" y="52" width="120" height="60" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 82px; margin-left: 461px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">PWA Bundle</div></div></div></foreignObject><text x="520" y="86" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">PWA Bundle</text></switch></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-18"><g><path d="M 550 243.37 L 550 256.94 L 690 256.94 L 690 243.37" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 550 238.12 L 553.5 245.12 L 550 243.37 L 546.5 245.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 690 238.12 L 693.5 245.12 L 690 243.37 L 686.5 245.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-19"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 258px; margin-left: 615px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Message</div></div></div></foreignObject><text x="615" y="261" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Message</text></switch></g></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-35"><g><rect x="460" y="177" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 207px; margin-left: 461px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">Backend Worker</div></div></div></foreignObject><text x="520" y="211" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Backend Worker</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-36"><g><path d="M 520 118.37 L 520 170.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 520 113.12 L 523.5 120.12 L 520 118.37 L 516.5 120.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 520 175.88 L 516.5 168.88 L 520 170.63 L 523.5 168.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-37"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 146px; margin-left: 522px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; "><div>Message</div></div></div></div></foreignObject><text x="522" y="149" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Message</text></switch></g></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-38"><g><path d="M 670 67 C 670 58.72 685.67 52 705 52 C 714.28 52 723.18 53.58 729.75 56.39 C 736.31 59.21 740 63.02 740 67 L 740 117 C 740 125.28 724.33 132 705 132 C 685.67 132 670 125.28 670 117 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 740 67 C 740 75.28 724.33 82 705 82 C 685.67 82 670 75.28 670 67" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 68px; height: 1px; padding-top: 105px; margin-left: 671px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>IndexedDB</div><div>/ OPFS<br /></div></div></div></div></foreignObject><text x="705" y="109" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">IndexedDB...</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-39"><g><path d="M 660 177 L 673.69 129.56" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 675.15 124.51 L 676.57 132.21 L 673.69 129.56 L 669.85 130.27 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 150px; margin-left: 668px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Write</div></div></div></foreignObject><text x="668" y="153" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Write</text></switch></g></g></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-40"><g><path d="M 729.85 127.65 L 717.22 170.89" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 715.75 175.93 L 714.36 168.23 L 717.22 170.89 L 721.08 170.19 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="Jth4lL2z7hU5_HQBh-La-41"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 153px; margin-left: 725px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Read</div></div></div></foreignObject><text x="725" y="157" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Read</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-16"><g><rect x="630" y="177" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 207px; margin-left: 631px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">SQLite Worker</div></div></div></foreignObject><text x="690" y="211" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">SQLite Worker</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-2"><g><path d="M 493 327 L 470 327 L 470 527 L 493 527" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 493 327 L 760 327 L 760 527 L 493 527" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 493 327 L 493 527" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)rotate(-90 481.5 427)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 198px; height: 1px; padding-top: 427px; margin-left: 383px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; font-weight: bold; white-space: normal; word-wrap: normal; ">Web App</div></div></div></foreignObject><text x="482" y="431" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle" font-weight="bold">Web App</text></switch></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-27"><g><path d="M 610 408.37 L 610 440.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 610 403.12 L 613.5 410.12 L 610 408.37 L 606.5 410.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 610 445.88 L 606.5 438.88 L 610 440.63 L 613.5 438.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-28"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 428px; margin-left: 611px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">RPC</div></div></div></foreignObject><text x="611" y="431" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">RPC</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-5"><g><rect x="520" y="342" width="120" height="60" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 372px; margin-left: 521px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">Web Bundle</div></div></div></foreignObject><text x="580" y="376" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Web Bundle</text></switch></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-6"><g><path d="M 550 447 L 550 408.37" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 550 403.12 L 553.5 410.12 L 550 408.37 L 546.5 410.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-7"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 428px; margin-left: 553px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Serve</div></div></div></foreignObject><text x="553" y="431" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Serve</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-10"><g><rect x="520" y="447" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 477px; margin-left: 521px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>Web Backend</div></div></div></div></foreignObject><text x="580" y="481" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Web Backend</text></switch></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-29"><g><path d="M 670 452 C 670 443.72 685.67 437 705 437 C 714.28 437 723.18 438.58 729.75 441.39 C 736.31 444.21 740 448.02 740 452 L 740 502 C 740 510.28 724.33 517 705 517 C 685.67 517 670 510.28 670 502 Z" fill="#ffffff" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 740 452 C 740 460.28 724.33 467 705 467 C 685.67 467 670 460.28 670 452" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 68px; height: 1px; padding-top: 490px; margin-left: 671px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>PostgreSQL</div><div>/ SQLite</div></div></div></div></foreignObject><text x="705" y="494" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">PostgreSQL...</text></switch></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-30"><g><path d="M 640 477 L 663.63 477" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 668.88 477 L 661.88 480.5 L 663.63 477 L 661.88 473.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-32"><g><path d="M 354 407 L 354 567.06 L 110 567.06 L 110 513.37" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 110 508.12 L 113.5 515.12 L 110 513.37 L 106.5 515.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-33"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 566px; margin-left: 236px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Import</div></div></div></foreignObject><text x="236" y="569" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Import</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-34"><g><path d="M 414 407 L 414 567.06 L 580 567.06 L 580 513.37" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 580 508.12 L 583.5 515.12 L 580 513.37 L 576.5 515.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-35"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 568px; margin-left: 501px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Import</div></div></div></foreignObject><text x="501" y="571" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Import</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-38"><g><path d="M 414 347 L 414 292 L 490 292 L 490 243.37" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 490 238.12 L 493.5 245.12 L 490 243.37 L 486.5 245.12 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-39"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 290px; margin-left: 451px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Import</div></div></div></foreignObject><text x="451" y="293" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Import</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-31"><g><rect x="324" y="347" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 377px; margin-left: 325px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">Backend Core</div></div></div></foreignObject><text x="384" y="381" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Backend Core</text></switch></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-43"><g><path d="M 340 207 L 340 306.94 L 409 306.94 C 409 300.44 419 300.44 419 306.94 L 419 306.94 L 580 306.94 L 580 335.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke" style="stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/><path d="M 580 340.88 L 576.5 333.88 L 580 335.63 L 583.5 333.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all" style="fill: light-dark(rgb(0, 0, 0), rgb(255, 255, 255)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-44"><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 258px; margin-left: 340px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; background-color: #ffffff; "><div style="display: inline-block; font-size: 11px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; background-color: light-dark(#ffffff, var(--ge-dark-color, #121212)); white-space: nowrap; ">Bundle</div></div></div></foreignObject><text x="340" y="261" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="11px" text-anchor="middle">Bundle</text></switch></g></g></g></g><g data-cell-id="f4uEDvzRM5RpCF_URP4V-40"><g><rect x="250" y="147" width="120" height="60" rx="9" ry="9" fill="#ffffff" stroke="#000000" pointer-events="all" style="fill: light-dark(#ffffff, var(--ge-dark-color, #121212)); stroke: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));"/></g><g><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 177px; margin-left: 251px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; color: #000000; "><div style="display: inline-block; font-size: 12px; font-family: "Helvetica"; color: light-dark(#000000, #ffffff); line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; "><div>Web UI</div></div></div></div></foreignObject><text x="310" y="181" fill="light-dark(#000000, #ffffff)" font-family=""Helvetica"" font-size="12px" text-anchor="middle">Web UI</text></switch></g></g></g></g></g></g><switch><g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/><a transform="translate(0,-5)" xlink:href="https://www.drawio.com/doc/faq/svg-export-text-problems" target="_blank"><text text-anchor="middle" font-size="10px" x="50%" y="100%">Text is not SVG - cannot display</text></a></switch></svg>
-
-
docs/assets/architecture.svg.license (deleted)
-
@@ -1,5 +0,0 @@[draw.io](https://app.diagrams.net/) 上で作成。 このファイルを draw.io 上で開くことで編集が可能。 SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
-
@@ -4,7 +4,6 @@ //// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only { "excludes": ["vendor"], "biome": { "lineWidth": 90 },
-
@@ -13,17 +12,9 @@ "lineWidth": 100}, "exec": { "commands": [ { "exts": ["proto"], "command": "bunx buf format {{file_path}}" }, { "exts": ["tf"], "command": "terraform fmt -" }, { "exts": ["go"], "command": "gofmt" } ] },
-
-
go.work (deleted)
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only go 1.24.1 use ( ./packages/backend ./packages/proto ./vendor/go-sqlite3-js )
-
-
go.work.sum (deleted)
-
@@ -1,39 +0,0 @@github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/tmaxmax/go-sse v0.8.0/go.mod h1:HLoxqxdH+7oSUItjtnpxjzJedfr/+Rrm/dNWBcTxJFM= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-
-
-
@@ -1,7 +1,7 @@{ "name": "@yamori/workspace", "private": true, "workspaces": ["packages/*"], "workspaces": [], "scripts": { "check": "wireit", "make": "wireit",
-
@@ -10,29 +10,22 @@ "clean": "bun run --filter '*' clean"}, "wireit": { "make": { "dependencies": ["./packages/pwa:make", "./packages/backend:bin"], "dependencies": [], "packageLocks": ["bun.lockb"] }, "reuse-lint": { "command": "reuse lint --lines" }, "check": { "dependencies": [ "./packages/proto:check", "./packages/idb_backend:check", "./packages/react_ui:check", "./packages/pwa:check", "reuse-lint" ], "dependencies": [], "packageLocks": ["bun.lockb"] }, "dev": { "dependencies": ["./packages/react_ui:dev", "./packages/pwa:dev"], "dependencies": [], "packageLocks": ["bun.lockb"] } }, "devDependencies": { "@bufbuild/buf": "^1.47.2", "wireit": "^0.14.9" } }
-
-
packages/assets/favicon-dark.png (deleted)
-
packages/assets/favicon-dark.svg (deleted)
-
@@ -1,1 +0,0 @@<svg xmlns="http://www.w3.org/2000/svg" width="101" height="101" fill="none"><rect width="100" height="100" x=".844" y=".75" fill="#EDEEF0" rx="9"/><path fill="#1C2024" d="M77.777 73.203V49.156c0-1.625-.203-2.906-.61-3.843-.374-.97-.952-1.672-1.734-2.11-.78-.437-1.75-.656-2.906-.656-1.219 0-2.25.25-3.094.75-.812.5-1.421 1.219-1.828 2.156-.406.906-.61 2-.61 3.281h-12.42c0-2.156.42-4.187 1.265-6.093a15.072 15.072 0 0 1 3.656-5.11c1.625-1.5 3.562-2.672 5.812-3.515 2.282-.844 4.813-1.266 7.594-1.266 3.344 0 6.313.563 8.906 1.688 2.594 1.124 4.641 2.906 6.141 5.343 1.5 2.438 2.25 5.61 2.25 9.516v22.969c0 2.687.14 4.937.422 6.75.312 1.78.766 3.312 1.36 4.593v.797H79.464c-.563-1.344-.985-3.031-1.266-5.062a45.397 45.397 0 0 1-.422-6.14Zm1.547-19.781.047 7.64h-4.969c-1.375 0-2.578.204-3.61.61-1.03.406-1.905.984-2.624 1.734a7.329 7.329 0 0 0-1.547 2.672c-.344 1-.516 2.078-.516 3.234 0 1.344.188 2.47.563 3.376.375.906.937 1.593 1.687 2.062.75.438 1.657.656 2.72.656 1.562 0 2.905-.328 4.03-.984 1.157-.656 2.016-1.438 2.578-2.344.594-.937.797-1.812.61-2.625l2.812 4.922c-.312 1.188-.797 2.422-1.453 3.703a17.853 17.853 0 0 1-2.484 3.563c-1 1.093-2.219 1.984-3.656 2.671-1.438.688-3.141 1.032-5.11 1.032-2.75 0-5.25-.61-7.5-1.828-2.219-1.25-3.984-2.985-5.297-5.204-1.28-2.25-1.922-4.921-1.922-8.015 0-2.563.422-4.875 1.266-6.938.844-2.093 2.078-3.859 3.703-5.297 1.656-1.468 3.75-2.609 6.281-3.421 2.532-.813 5.5-1.22 8.907-1.22h5.484ZM23.817 16.156 34.785 47.47 45.66 16.156h14.157l-18.563 43.36v24.89H28.223v-24.89L9.707 16.156h14.11Z"/></svg>
-
-
packages/assets/favicon-light.png (deleted)
-
packages/assets/favicon-light.svg (deleted)
-
@@ -1,1 +0,0 @@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="none"><rect width="100" height="100" fill="#1C2024" rx="9"/><path fill="#EDEEF0" d="M76.933 72.453V48.406c0-1.625-.203-2.906-.609-3.843-.375-.97-.953-1.672-1.734-2.11-.782-.437-1.75-.656-2.907-.656-1.218 0-2.25.25-3.093.75-.813.5-1.422 1.219-1.828 2.156-.407.906-.61 2-.61 3.281H53.73c0-2.156.422-4.187 1.266-6.093a15.072 15.072 0 0 1 3.656-5.11c1.625-1.5 3.563-2.672 5.813-3.515 2.28-.844 4.812-1.266 7.593-1.266 3.344 0 6.313.563 8.907 1.688 2.593 1.124 4.64 2.906 6.14 5.343 1.5 2.438 2.25 5.61 2.25 9.516v22.969c0 2.687.14 4.937.422 6.75.313 1.78.766 3.312 1.36 4.593v.797H78.62c-.563-1.344-.984-3.031-1.266-5.062a45.413 45.413 0 0 1-.422-6.14Zm1.547-19.781.047 7.64h-4.969c-1.375 0-2.578.204-3.609.61-1.031.406-1.906.984-2.625 1.734a7.327 7.327 0 0 0-1.547 2.672c-.344 1-.515 2.078-.515 3.234 0 1.344.187 2.47.562 3.376.375.906.938 1.593 1.688 2.062.75.438 1.656.656 2.718.656 1.563 0 2.907-.328 4.032-.984 1.156-.656 2.015-1.438 2.578-2.344.593-.937.797-1.812.61-2.625l2.812 4.922c-.313 1.188-.797 2.422-1.454 3.703a17.849 17.849 0 0 1-2.484 3.563c-1 1.093-2.219 1.984-3.656 2.671-1.438.688-3.14 1.032-5.11 1.032-2.75 0-5.25-.61-7.5-1.828-2.218-1.25-3.984-2.985-5.296-5.204-1.282-2.25-1.922-4.921-1.922-8.015 0-2.563.422-4.875 1.265-6.938.844-2.093 2.078-3.859 3.703-5.297 1.657-1.468 3.75-2.609 6.282-3.421 2.53-.813 5.5-1.22 8.906-1.22h5.484ZM22.973 15.406 33.942 46.72l10.875-31.313h14.156L40.41 58.766v24.89H27.38v-24.89L8.862 15.406h14.11Z"/></svg>
-
-
packages/backend/.gitignore (deleted)
-
@@ -1,14 +0,0 @@# このディレクトリ特有の無視設定。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only # What: Go 製サーバの実行ファイル。 # Why: バイナリ。 /backend # What: ビルドされた WebAssembly ファイル。 /backend.wasm # What: ビルドされた WebAssembly に必要な Go の JS ランタイム。 /backend_prelude.js
-
-
packages/backend/core/auth.go (deleted)
-
@@ -1,110 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package core import ( "fmt" "net/http" "time" "github.com/golang-jwt/jwt/v5" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" "pocka.jp/x/yamori/backend/core/projection" ) const cookieName = "yamori-login-token" type token string func (core *Core) LoadTokenFromCookie(header *http.Header) (*token, error) { for _, header := range header.Values("Cookie") { cookies, err := http.ParseCookie(header) if err != nil { return nil, err } for _, cookie := range cookies { if cookie.Name == cookieName { token := token(cookie.Value) return &token, nil } } } return nil, nil } func DeleteTokenFromCookie(header *http.Header) { cookie := http.Cookie{ Name: cookieName, Value: "", Expires: time.Now(), SameSite: http.SameSiteStrictMode, Secure: true, HttpOnly: true, } header.Add("Set-Cookie", cookie.String()) } func (core *Core) IssueToken(secret *projection.LoginJwtSecret, user *workspace.Users_User) (*token, error) { t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": user.GetId(), }) signed, err := t.SignedString(secret.Projection) if err != nil { return nil, err } token := token(signed) return &token, nil } func (t *token) SaveToCookie(header *http.Header) { cookie := http.Cookie{ Name: cookieName, Value: string(*t), SameSite: http.SameSiteStrictMode, Secure: true, HttpOnly: true, } header.Add("Set-Cookie", cookie.String()) } func (t *token) Validate(secret *projection.LoginJwtSecret) error { _, err := jwt.Parse(string(*t), func(token *jwt.Token) (any, error) { return secret.Projection, nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) return err } func (t *token) FindUser( secret *projection.LoginJwtSecret, users *projection.Users, ) (*workspace.Users_User, error) { parsed, err := jwt.Parse(string(*t), func(token *jwt.Token) (any, error) { return secret.Projection, nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) if err != nil { return nil, err } sub, err := parsed.Claims.GetSubject() if err != nil { return nil, err } for _, u := range users.Projection.Users { if u.GetId() == sub { return u, nil } } return nil, fmt.Errorf("No user found for sub=%s", sub) }
-
-
packages/backend/core/core.go (deleted)
-
@@ -1,152 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package core import ( "crypto/rand" "database/sql" "log/slog" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" "pocka.jp/x/yamori/backend/migrations" eventsV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" ) type Core struct { DB *sql.DB Logger *slog.Logger } func New(db *sql.DB, logger *slog.Logger) (*Core, error) { err := migrations.Run(db, logger, []migrations.Migration{ migrations.Migration001{}, migrations.Migration002{}, }) if err != nil { return nil, err } return &Core{ DB: db, Logger: logger, }, nil } func (core *Core) Init(adminCreationPassword string, overwriteJwtSecret bool) error { if err := core.generateAdminCreationPassword(adminCreationPassword); err != nil { return err } if err := core.setupLoginJwtSecret(overwriteJwtSecret); err != nil { return err } return nil } func (core *Core) generateAdminCreationPassword(givenPassword string) error { tx, err := core.DB.Begin() if err != nil { return err } defer tx.Rollback() pw, err := projection.GetAdminCreationPassword(tx) if err != nil { return err } workspace, err := projection.GetWorkspace(tx) if err != nil { return err } if err := event.UpdateProjections(tx, pw, workspace); err != nil { return err } if pw.Projection.Password != nil { return nil } if workspace.Projection.NumberOfAdmins != nil && *workspace.Projection.NumberOfAdmins > 0 { return nil } core.Logger.Debug("Configuring admin creation password...") password := givenPassword isPasswordAutogenerated := false if password == "" { password = rand.Text() isPasswordAutogenerated = true } err = event.AppendEvents(tx, []*eventsV1.Event{ workspaceEvent.GenerateAdminCreationPassword(password), }) if err != nil { return err } if err := tx.Commit(); err != nil { return err } if isPasswordAutogenerated { core.Logger.Info( "Generated admin creation password. Use this for initial admin user creation.", "admin_creation_password", password, ) } else { core.Logger.Info("Configured admin creation password. Use that for initial admin user creation.") } return nil } func (core *Core) setupLoginJwtSecret(overwrite bool) error { tx, err := core.DB.Begin() if err != nil { return err } defer tx.Rollback() p, err := projection.GetLoginJwtSecret(tx) if err != nil { return err } if p.HasSnapshot() { if overwrite { core.Logger.Warn( "Overwriting existing JWT secret. Tokens generated before won't be available anymore.", ) } else { core.Logger.Debug("Found JWT secret, skipping newly generating.") return nil } } err = event.AppendEvents(tx, []*eventsV1.Event{ workspaceEvent.ConfigureRandomLoginJwtSecret(), }) if err != nil { return err } if err := tx.Commit(); err != nil { return err } core.Logger.Info( "Generated a new secret for signing JWT.", ) return nil }
-
-
packages/backend/core/event/event.go (deleted)
-
@@ -1,105 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package event import ( "database/sql" "slices" "google.golang.org/protobuf/proto" eventsV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" ) type Event struct { Seq uint64 Payload *eventsV1.Event } func AppendEvents(tx *sql.Tx, events []*eventsV1.Event) error { stmt, err := tx.Prepare("INSERT INTO events (payload) VALUES (?)") if err != nil { return nil } for _, event := range events { b, err := proto.Marshal(event) if err != nil { return err } if _, err = stmt.Exec(b); err != nil { return err } } return nil } func listEvents(tx *sql.Tx, startSeq uint64) ([]Event, error) { rows, err := tx.Query("SELECT seq, payload FROM events WHERE seq >= ? ORDER BY seq ASC", startSeq) if err != nil { return nil, err } ret := make([]Event, 0) for rows.Next() { var seq uint64 var payload []byte if err := rows.Scan(&seq, &payload); err != nil { return nil, err } var event eventsV1.Event if err := proto.Unmarshal(payload, &event); err != nil { return nil, err } ret = append(ret, Event{ Seq: seq, Payload: &event, }) } return ret, nil } type Projection interface { EventSeq() *uint64 Update(events []Event) SaveSnapshot(tx *sql.Tx) error } func UpdateProjections(tx *sql.Tx, projections ...Projection) error { seqs := make([]uint64, 0, len(projections)) for _, p := range projections { seq := p.EventSeq() if seq == nil { continue } seqs = append(seqs, *seq) } startSeq := uint64(0) if len(seqs) > 0 { startSeq = slices.Min(seqs) } events, err := listEvents(tx, startSeq) if err != nil { return err } for _, p := range projections { p.Update(events) if err := p.SaveSnapshot(tx); err != nil { return err } } return nil }
-
-
-
@@ -1,90 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection import ( "database/sql" workspaceEventsv1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/snapshot" ) type AdminCreationPassword struct { hasSnapshot bool eventSeq *uint64 Projection *workspace.AdminCreationPassword } func NewAdminCreationPassword() *AdminCreationPassword { return &AdminCreationPassword{ hasSnapshot: false, eventSeq: nil, Projection: &workspace.AdminCreationPassword{ Password: nil, }, } } func GetAdminCreationPassword(tx *sql.Tx) (*AdminCreationPassword, error) { payload, seq, err := snapshot.GetLatest[workspace.AdminCreationPassword](tx) if err != nil { return nil, err } if payload == nil { return NewAdminCreationPassword(), nil } return &AdminCreationPassword{ hasSnapshot: true, eventSeq: &seq, Projection: payload, }, nil } func (p *AdminCreationPassword) EventSeq() *uint64 { return p.eventSeq } func (p *AdminCreationPassword) Update(events []event.Event) { if len(events) == 0 || p.Projection == nil { return } for i := range events { if p.eventSeq != nil && events[i].Seq <= *p.eventSeq { continue } ev := events[i].Payload.GetWorkspaceEvent() if ev == nil { continue } p.hasSnapshot = false switch ev := ev.Event.(type) { case *workspaceEventsv1.Event_AdminCreationPasswordGenerated: p.Projection.Password = &workspace.AdminCreationPassword_Password{ Hash: ev.AdminCreationPasswordGenerated.PasswordHash, Salt: ev.AdminCreationPasswordGenerated.PasswordSalt, } case *workspaceEventsv1.Event_AdminCreationPasswordExpired: p.Projection.Password = nil } p.eventSeq = &events[i].Seq } } func (p *AdminCreationPassword) SaveSnapshot(tx *sql.Tx) error { if p.hasSnapshot || p.eventSeq == nil { return nil } return snapshot.Save(tx, p.Projection, *p.eventSeq) }
-
-
-
@@ -1,92 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection import ( "crypto/rand" "database/sql" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/snapshot" workspaceEventsv1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" ) type LoginJwtSecret struct { hasSnapshot bool eventSeq *uint64 Projection []byte } func NewLoginJwtSecret() *LoginJwtSecret { secret := make([]byte, 56) rand.Read(secret) return &LoginJwtSecret{ hasSnapshot: false, eventSeq: nil, Projection: secret, } } func GetLoginJwtSecret(tx *sql.Tx) (*LoginJwtSecret, error) { payload, seq, err := snapshot.GetLatest[workspace.LoginJwtSecret](tx) if err != nil { return nil, err } if payload == nil { return NewLoginJwtSecret(), nil } return &LoginJwtSecret{ hasSnapshot: true, eventSeq: &seq, Projection: payload.Secret, }, nil } func (p *LoginJwtSecret) HasSnapshot() bool { return p.hasSnapshot } func (p *LoginJwtSecret) EventSeq() *uint64 { return p.eventSeq } func (p *LoginJwtSecret) Update(events []event.Event) { if len(events) == 0 || p.Projection == nil { return } for i := range events { if p.eventSeq != nil && events[i].Seq <= *p.eventSeq { continue } ev := events[i].Payload.GetWorkspaceEvent() if ev == nil { continue } p.hasSnapshot = false switch v := ev.Event.(type) { case *workspaceEventsv1.Event_LoginJwtSecretConfigured: p.Projection = v.LoginJwtSecretConfigured.Secret } p.eventSeq = &events[i].Seq } } func (p *LoginJwtSecret) SaveSnapshot(tx *sql.Tx) error { if p.hasSnapshot || p.eventSeq == nil { return nil } return snapshot.Save(tx, &workspace.LoginJwtSecret{ Secret: p.Projection, }, *p.eventSeq) }
-
-
-
@@ -1,246 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection import ( "database/sql" "slices" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/snapshot" workspaceEventsv1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" ) type Users struct { hasSnapshot bool eventSeq *uint64 Projection *workspace.Users permissions map[string]map[types.Permission]struct{} // { カスタムフィールド定義ID: { ユーザID: 値 } } customAttributes map[string]map[string]string } func NewUsers() *Users { return &Users{ hasSnapshot: false, eventSeq: nil, Projection: &workspace.Users{ Users: []*workspace.Users_User{}, }, permissions: make(map[string]map[types.Permission]struct{}), customAttributes: make(map[string]map[string]string), } } func GetUsers(tx *sql.Tx) (*Users, error) { payload, seq, err := snapshot.GetLatest[workspace.Users](tx) if err != nil { return nil, err } if payload == nil { return NewUsers(), nil } permissions := make(map[string]map[types.Permission]struct{}) customAttributes := make(map[string]map[string]string) for _, u := range payload.Users { perms := make(map[types.Permission]struct{}) for _, p := range u.Permissions { perms[p] = struct{}{} } permissions[u.GetId()] = perms for _, c := range u.CustomAttributes { defId := c.GetId() if customAttributes[defId] == nil { customAttributes[defId] = make(map[string]string) } customAttributes[defId][u.GetId()] = c.GetValue() } } return &Users{ hasSnapshot: true, eventSeq: &seq, Projection: payload, permissions: permissions, customAttributes: customAttributes, }, nil } func (p *Users) EventSeq() *uint64 { return p.eventSeq } var fullAccess = []types.Permission{ types.Permission_PERMISSION_ADD_REGULAR_USER, types.Permission_PERMISSION_ADD_ADMIN_USER, types.Permission_PERMISSION_DELETE_REGULAR_USER, types.Permission_PERMISSION_DELETE_ADMIN_USER, types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE, types.Permission_PERMISSION_READ_ADMIN_USER_PROFILE, types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE, types.Permission_PERMISSION_UPDATE_ADMIN_USER_PROFILE, types.Permission_PERMISSION_UPDATE_SELF_PROFILE, types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD, types.Permission_PERMISSION_UPDATE_ADMIN_USER_LOGIN_METHOD, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE, } func (p *Users) Update(events []event.Event) { if len(events) == 0 || p.Projection == nil { return } for _, container := range events { if p.eventSeq != nil && container.Seq <= *p.eventSeq { continue } ev := container.Payload.GetWorkspaceEvent() if ev == nil { continue } p.hasSnapshot = false switch ev := ev.Event.(type) { case *workspaceEventsv1.Event_UserCreated: p.Projection.Users = append(p.Projection.Users, &workspace.Users_User{ Id: ev.UserCreated.Id, Name: ev.UserCreated.Name, DisplayName: ev.UserCreated.DisplayName, KeyId: ev.UserCreated.KeyId, IsAdmin: proto.Bool(false), PasswordLogin: nil, }) p.permissions[ev.UserCreated.GetId()] = make(map[types.Permission]struct{}) case *workspaceEventsv1.Event_UserUpdated: id := ev.UserUpdated.GetId() for _, user := range p.Projection.Users { if user.GetId() == id { if ev.UserUpdated.GetName() != "" { user.Name = ev.UserUpdated.Name } if ev.UserUpdated.GetDisplayName() != "" { user.DisplayName = ev.UserUpdated.DisplayName } } } case *workspaceEventsv1.Event_UserDeleted: id := ev.UserDeleted.GetId() for i := len(p.Projection.Users) - 1; i >= 0; i-- { if p.Projection.Users[i].GetId() == id { p.Projection.Users = slices.Delete(p.Projection.Users, i, i+1) } } delete(p.permissions, id) case *workspaceEventsv1.Event_AdminAccessGranted: id := ev.AdminAccessGranted.GetUserId() for _, u := range p.Projection.Users { if u.GetId() == id { u.IsAdmin = proto.Bool(true) } } case *workspaceEventsv1.Event_AdminAccessRevoked: id := ev.AdminAccessRevoked.GetUserId() for _, u := range p.Projection.Users { if u.GetId() == id { u.IsAdmin = proto.Bool(false) } } case *workspaceEventsv1.Event_PasswordLoginConfigured: id := ev.PasswordLoginConfigured.GetUserId() for _, u := range p.Projection.Users { if u.GetId() == id { u.PasswordLogin = &workspace.Users_PasswordLogin{ Hash: ev.PasswordLoginConfigured.PasswordHash, Salt: ev.PasswordLoginConfigured.PasswordSalt, } } } case *workspaceEventsv1.Event_UserPermissionsGranted: id := ev.UserPermissionsGranted.GetUserId() if p.permissions[id] != nil { for _, perm := range ev.UserPermissionsGranted.Permissions { p.permissions[id][perm] = struct{}{} } } case *workspaceEventsv1.Event_UserPermissionsRevoked: id := ev.UserPermissionsRevoked.GetUserId() if p.permissions[id] != nil { for _, perm := range ev.UserPermissionsRevoked.Permissions { delete(p.permissions[id], perm) } } case *workspaceEventsv1.Event_CustomAttributeDefined: p.customAttributes[ev.CustomAttributeDefined.GetId()] = make(map[string]string) case *workspaceEventsv1.Event_CustomAttributeUndefined: delete(p.customAttributes, ev.CustomAttributeUndefined.GetId()) case *workspaceEventsv1.Event_CustomAttributeSet: defId := ev.CustomAttributeSet.GetCustomAttributeId() if p.customAttributes[defId] != nil { p.customAttributes[defId][ev.CustomAttributeSet.GetUserId()] = ev.CustomAttributeSet.GetValue() } } for _, user := range p.Projection.Users { if user.GetIsAdmin() { user.Permissions = slices.Clone(fullAccess) continue } userId := user.GetId() permissions := p.permissions[userId] s := make([]types.Permission, 0, len(permissions)) for perm := range permissions { s = append(s, perm) } c := make([]*workspace.Users_User_CustomAttribute, 0, len(p.customAttributes)) for defId, values := range p.customAttributes { c = append(c, &workspace.Users_User_CustomAttribute{ Id: proto.String(defId), Value: proto.String(values[userId]), }) } slices.Sort(s) user.Permissions = slices.Compact(s) user.CustomAttributes = c } p.eventSeq = &container.Seq } } func (p *Users) SaveSnapshot(tx *sql.Tx) error { if p.hasSnapshot || p.eventSeq == nil { return nil } return snapshot.Save(tx, p.Projection, *p.eventSeq) }
-
-
-
@@ -1,139 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection_test import ( "database/sql" "io" "log/slog" "testing" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" _ "modernc.org/sqlite" ) // Permission メッセージのフィールド番号の最も大きい数字 const PERMISSION_MAX_NUMBER = 12 func TestUserPermissions(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } core, err := core.New(db, logger) if err != nil { t.Fatal(err) } tx, err := core.DB.Begin() if err != nil { t.Fatal(err) } err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.CreateUser("foo", "foo", "Foo", []byte{}), workspaceEvent.GrantPermission("foo", []types.Permission{ types.Permission_PERMISSION_DELETE_REGULAR_USER, types.Permission_PERMISSION_ADD_REGULAR_USER, }), workspaceEvent.RevokePermission("foo", []types.Permission{ types.Permission_PERMISSION_DELETE_REGULAR_USER, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE, }), }) if err != nil { t.Fatal(err) } p, err := projection.GetUsers(tx) if err != nil { t.Fatal(err) } if err := event.UpdateProjections(tx, p); err != nil { t.Fatal(err) } for _, u := range p.Projection.Users { if u.GetId() == "foo" { if len(u.Permissions) != 1 { t.Errorf("Expected a slice of length of 1, got length of %d", len(u.Permissions)) } if u.Permissions[0] != types.Permission_PERMISSION_ADD_REGULAR_USER { t.Errorf("Expected ADD_REGULAR_USER, got %v", u.Permissions[0]) } return } } t.Errorf("User foo not created") } func TestAdminHaveFullPermissions(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } core, err := core.New(db, logger) if err != nil { t.Fatal(err) } tx, err := core.DB.Begin() if err != nil { t.Fatal(err) } err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.CreateUser("foo", "foo", "Foo", []byte{}), workspaceEvent.GrantAdminAccess("foo"), workspaceEvent.GrantPermission("foo", []types.Permission{ types.Permission_PERMISSION_ADD_REGULAR_USER, }), workspaceEvent.RevokePermission("foo", []types.Permission{ types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE, }), }) if err != nil { t.Fatal(err) } p, err := projection.GetUsers(tx) if err != nil { t.Fatal(err) } if err := event.UpdateProjections(tx, p); err != nil { t.Fatal(err) } for _, u := range p.Projection.Users { if u.GetId() == "foo" { if len(u.Permissions) != PERMISSION_MAX_NUMBER { t.Errorf( "Expected a slice of length of %d, got length of %d", PERMISSION_MAX_NUMBER, len(u.Permissions), ) } return } } t.Errorf("User foo not created") }
-
-
-
@@ -1,138 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection import ( "database/sql" "slices" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/snapshot" workspaceEventsv1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" workspace "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" ) type Workspace struct { hasSnapshot bool eventSeq *uint64 Projection *workspace.Workspace } func NewWorkspace() *Workspace { return &Workspace{ hasSnapshot: false, eventSeq: nil, Projection: &workspace.Workspace{ Abbreviations: &workspace.Workspace_Abbreviations{ DayOff: proto.String("公休"), Worked: proto.String("出勤"), SkipWork: proto.String("欠勤"), PaidLeave: proto.String("年休"), }, NumberOfAdmins: proto.Uint32(0), CustomAttributes: []*workspace.Workspace_CustomAttribute{}, }, } } func GetWorkspace(tx *sql.Tx) (*Workspace, error) { payload, seq, err := snapshot.GetLatest[workspace.Workspace](tx) if err != nil { return nil, err } if payload == nil { return NewWorkspace(), nil } return &Workspace{ hasSnapshot: true, eventSeq: &seq, Projection: payload, }, nil } func (p *Workspace) EventSeq() *uint64 { return p.eventSeq } func (p *Workspace) Update(events []event.Event) { if len(events) == 0 || p.Projection == nil { return } for i := range events { if p.eventSeq != nil && events[i].Seq <= *p.eventSeq { continue } ev := events[i].Payload.GetWorkspaceEvent() if ev == nil { continue } p.hasSnapshot = false switch v := ev.Event.(type) { case *workspaceEventsv1.Event_AdminAccessGranted: p.Projection.NumberOfAdmins = proto.Uint32(*p.Projection.NumberOfAdmins + 1) case *workspaceEventsv1.Event_AdminAccessRevoked: p.Projection.NumberOfAdmins = proto.Uint32(max(0, *p.Projection.NumberOfAdmins-1)) case *workspaceEventsv1.Event_WorkspaceDisplayNameSet: p.Projection.DisplayName = v.WorkspaceDisplayNameSet.DisplayName case *workspaceEventsv1.Event_AbbreviationsConfigured: if v.AbbreviationsConfigured.GetDayOff() != "" { p.Projection.Abbreviations.DayOff = v.AbbreviationsConfigured.DayOff } if v.AbbreviationsConfigured.GetWorked() != "" { p.Projection.Abbreviations.Worked = v.AbbreviationsConfigured.Worked } if v.AbbreviationsConfigured.GetSkipWork() != "" { p.Projection.Abbreviations.SkipWork = v.AbbreviationsConfigured.SkipWork } if v.AbbreviationsConfigured.GetPaidLeave() != "" { p.Projection.Abbreviations.PaidLeave = v.AbbreviationsConfigured.PaidLeave } case *workspaceEventsv1.Event_CustomAttributeDefined: id := v.CustomAttributeDefined.GetId() found := false for _, attr := range p.Projection.CustomAttributes { if attr.GetId() == id { attr.DisplayName = v.CustomAttributeDefined.DisplayName found = true break } } if !found { p.Projection.CustomAttributes = append(p.Projection.CustomAttributes, &workspace.Workspace_CustomAttribute{ Id: v.CustomAttributeDefined.Id, DisplayName: v.CustomAttributeDefined.DisplayName, }) } case *workspaceEventsv1.Event_CustomAttributeUndefined: for i := len(p.Projection.CustomAttributes) - 1; i >= 0; i-- { if p.Projection.CustomAttributes[i].GetId() == v.CustomAttributeUndefined.GetId() { p.Projection.CustomAttributes = slices.Delete(p.Projection.CustomAttributes, i, i+1) } } } p.eventSeq = &events[i].Seq } } func (p *Workspace) SaveSnapshot(tx *sql.Tx) error { if p.hasSnapshot || p.eventSeq == nil { return nil } return snapshot.Save(tx, p.Projection, *p.eventSeq) }
-
-
-
@@ -1,253 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package projection_test import ( "database/sql" "io" "log/slog" "testing" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" workspaceV1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" _ "modernc.org/sqlite" ) func TestSetDisplayName(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } core, err := core.New(db, logger) if err != nil { t.Fatal(err) } tx, err := core.DB.Begin() if err != nil { t.Fatal(err) } err = event.AppendEvents(tx, []*eventV1.Event{ { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_WorkspaceDisplayNameSet{ WorkspaceDisplayNameSet: &workspaceV1.WorkspaceDisplayNameSet{ DisplayName: proto.String("Foo Bar"), }, }, }, }, }, }) if err != nil { t.Fatal(err) } p, err := projection.GetWorkspace(tx) if err != nil { t.Fatal(err) } if err := event.UpdateProjections(tx, p); err != nil { t.Fatal(err) } if p.Projection.GetDisplayName() != "Foo Bar" { t.Errorf("Expected \"Foo Bar\", got \"%s\"", p.Projection.GetDisplayName()) } } func TestDefaultAbbreviationsSet(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } core, err := core.New(db, logger) if err != nil { t.Fatal(err) } tx, err := core.DB.Begin() if err != nil { t.Fatal(err) } p, err := projection.GetWorkspace(tx) if err != nil { t.Fatal(err) } if err := event.UpdateProjections(tx, p); err != nil { t.Fatal(err) } abbr := p.Projection.GetAbbreviations() if abbr.GetDayOff() == "" { t.Error("day_off has no default abbreviation") } if abbr.GetWorked() == "" { t.Error("worked has no default abbreviation") } if abbr.GetSkipWork() == "" { t.Error("skip_work has no default abbreviation") } if abbr.GetPaidLeave() == "" { t.Error("paid_leave has no default abbreviation") } } func TestCustomAttributes(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } core, err := core.New(db, logger) if err != nil { t.Fatal(err) } tx, err := core.DB.Begin() if err != nil { t.Fatal(err) } err = event.AppendEvents(tx, []*eventV1.Event{ // Should be ignored { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_CustomAttributeUndefined{ CustomAttributeUndefined: &workspaceV1.CustomAttributeUndefined{ Id: proto.String("foo"), }, }, }, }, }, { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_CustomAttributeDefined{ CustomAttributeDefined: &workspaceV1.CustomAttributeDefined{ Id: proto.String("foo"), DisplayName: proto.String("Foo"), }, }, }, }, }, { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_CustomAttributeDefined{ CustomAttributeDefined: &workspaceV1.CustomAttributeDefined{ Id: proto.String("bar"), DisplayName: proto.String("Bar"), }, }, }, }, }, { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_CustomAttributeDefined{ CustomAttributeDefined: &workspaceV1.CustomAttributeDefined{ Id: proto.String("baz"), DisplayName: proto.String("Baz"), }, }, }, }, }, { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_CustomAttributeDefined{ CustomAttributeDefined: &workspaceV1.CustomAttributeDefined{ Id: proto.String("baz"), DisplayName: proto.String("Baz Baz"), }, }, }, }, }, { Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_CustomAttributeUndefined{ CustomAttributeUndefined: &workspaceV1.CustomAttributeUndefined{ Id: proto.String("bar"), }, }, }, }, }, }) if err != nil { t.Fatal(err) } p, err := projection.GetWorkspace(tx) if err != nil { t.Fatal(err) } if err := event.UpdateProjections(tx, p); err != nil { t.Fatal(err) } foo := p.Projection.CustomAttributes[0] if foo == nil { t.Error("Expected foo to be set, got nil") } else { if foo.GetId() != "foo" { t.Errorf("Expected foo to have ID \"foo\", got \"%s\"", foo.GetId()) } if foo.GetDisplayName() != "Foo" { t.Errorf("Expected foo to be named \"Foo\", got \"%s\"", foo.GetDisplayName()) } } if len(p.Projection.CustomAttributes) != 2 { t.Errorf( "Expected custom attribute definitions to have 2 items, got %d", len(p.Projection.CustomAttributes), ) } baz := p.Projection.CustomAttributes[1] if baz == nil { t.Error("Expected bar to be set, got nil") } else { switch baz.GetDisplayName() { case "Baz": t.Error("Name of baz was not updated") case "Baz Baz": break default: t.Errorf("Expected baz to be named \"Baz Baz\", got \"%s\"", baz.GetDisplayName()) } } }
-
-
-
@@ -1,73 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package snapshot import ( "database/sql" "google.golang.org/protobuf/proto" ) // Zig に戻りたい...。 // https://konradreiche.com/blog/a-generic-protobuf-reader-with-go-type-parameters/ type ProtoMessage[T any] interface { proto.Message *T } func GetLatest[T any, P ProtoMessage[T]](tx *sql.Tx) (P, uint64, error) { var ret P = new(T) stmt, err := tx.Prepare(` SELECT event_seq, payload FROM snapshots LEFT JOIN projections ON snapshots.projection_id = projections.id WHERE projection_name = ? ORDER BY event_seq DESC LIMIT 1 `) if err != nil { return nil, 0, err } row := stmt.QueryRow(ret.ProtoReflect().Descriptor().FullName()) var seq uint64 var payload []byte err = row.Scan(&seq, &payload) if err == sql.ErrNoRows { return nil, 0, nil } if err != nil { return nil, 0, err } if err := proto.Unmarshal(payload, ret); err != nil { return nil, 0, err } return ret, seq, nil } func Save(tx *sql.Tx, msg proto.Message, seq uint64) error { name := msg.ProtoReflect().Descriptor().FullName() payload, err := proto.Marshal(msg) if err != nil { return err } _, err = tx.Exec(` INSERT INTO projections (projection_name) SELECT ?1 WHERE NOT EXISTS (SELECT id FROM projections WHERE projection_name = ?1) `, name) if err != nil { return err } _, err = tx.Exec(` INSERT INTO snapshots (event_seq, projection_id, payload) SELECT ?, id, ? FROM projections WHERE projection_name = ? LIMIT 1 `, seq, payload, name) return err }
-
-
packages/backend/crypto/password.go (deleted)
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package crypto import ( "crypto/rand" "golang.org/x/crypto/argon2" ) func HashPassword(password []byte, salt []byte) []byte { // RFC 推奨のパラメータ (Go のドキュメントに書いてあるもの) return argon2.IDKey(password, salt, 1, 64*1024, 4, 32) } // SaltAndHashPassword は自動生成したソルトでパスワードをハッシュし、 // 生成されたハッシュとソルトを返す。 func SaltAndHashPassword(password []byte) ([]byte, []byte) { salt := make([]byte, 32) // ドキュメントに書いてあるとおり `rand.Read()` はエラーを返さない。 rand.Read(salt) return HashPassword(password, salt), salt }
-
-
-
@@ -1,255 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "crypto/rand" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/crypto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" workspaceEvent "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" ) func GenerateAdminCreationPassword(password string) *eventV1.Event { hash, salt := crypto.SaltAndHashPassword([]byte(password)) return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminCreationPasswordGenerated{ AdminCreationPasswordGenerated: &workspaceEvent.AdminCreationPasswordGenerated{ PasswordHash: hash, PasswordSalt: salt, }, }, }, }, } } func ExpireAdminCreationPassword() *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminCreationPasswordExpired{ AdminCreationPasswordExpired: &workspaceEvent.AdminCreationPasswordExpired{}, }, }, }, } } func CreateUser(id string, name string, displayName string, keyID []byte) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserCreated{ UserCreated: &workspaceEvent.UserCreated{ Id: proto.String(id), Name: proto.String(name), DisplayName: proto.String(displayName), KeyId: keyID, }, }, }, }, } } func UpdateUser(id string, name string, displayName string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserUpdated{ UserUpdated: &workspaceEvent.UserUpdated{ Id: proto.String(id), Name: proto.String(name), DisplayName: proto.String(displayName), }, }, }, }, } } func DeleteUser(id string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserDeleted{ UserDeleted: &workspaceEvent.UserDeleted{ Id: proto.String(id), }, }, }, }, } } func ConfigurePasswordLogin(userID string, password string) *eventV1.Event { hash, salt := crypto.SaltAndHashPassword([]byte(password)) return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_PasswordLoginConfigured{ PasswordLoginConfigured: &workspaceEvent.PasswordLoginConfigured{ UserId: proto.String(userID), PasswordHash: hash, PasswordSalt: salt, }, }, }, }, } } func GrantAdminAccess(userID string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminAccessGranted{ AdminAccessGranted: &workspaceEvent.AdminAccessGranted{ UserId: proto.String(userID), }, }, }, }, } } func RevokeAdminAccess(userID string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AdminAccessRevoked{ AdminAccessRevoked: &workspaceEvent.AdminAccessRevoked{ UserId: proto.String(userID), }, }, }, }, } } func ConfigureRandomLoginJwtSecret() *eventV1.Event { secret := make([]byte, 48) rand.Read(secret) return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_LoginJwtSecretConfigured{ LoginJwtSecretConfigured: &workspaceEvent.LoginJwtSecretConfigured{ Secret: secret, }, }, }, }, } } func GrantPermission(userID string, permissions []types.Permission) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserPermissionsGranted{ UserPermissionsGranted: &workspaceEvent.UserPermissionsGranted{ UserId: proto.String(userID), Permissions: permissions, }, }, }, }, } } func RevokePermission(userID string, permissions []types.Permission) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserPermissionsRevoked{ UserPermissionsRevoked: &workspaceEvent.UserPermissionsRevoked{ UserId: proto.String(userID), Permissions: permissions, }, }, }, }, } } func ConfigureAbbreviations(abbr *workspaceEvent.AbbreviationsConfigured) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_AbbreviationsConfigured{ AbbreviationsConfigured: abbr, }, }, }, } } func SetDisplayName(displayName string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_WorkspaceDisplayNameSet{ WorkspaceDisplayNameSet: &workspaceEvent.WorkspaceDisplayNameSet{ DisplayName: proto.String(displayName), }, }, }, }, } } func DefineCustomAttributeDefinition(id string, displayName string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_CustomAttributeDefined{ CustomAttributeDefined: &workspaceEvent.CustomAttributeDefined{ Id: proto.String(id), DisplayName: proto.String(displayName), }, }, }, }, } } func UndefineCustomAttributeDefinition(id string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_CustomAttributeUndefined{ CustomAttributeUndefined: &workspaceEvent.CustomAttributeUndefined{ Id: proto.String(id), }, }, }, }, } } func SetCustomAttribute(userId string, id string, value string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_CustomAttributeSet{ CustomAttributeSet: &workspaceEvent.CustomAttributeSet{ UserId: proto.String(userId), CustomAttributeId: proto.String(id), Value: proto.String(value), }, }, }, }, } }
-
-
packages/backend/go.mod (deleted)
-
@@ -1,47 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only module pocka.jp/x/yamori/backend go 1.24.1 require ( connectrpc.com/connect v1.18.1 github.com/nlepage/go-wasm-http-server/v2 v2.2.1 golang.org/x/net v0.23.0 google.golang.org/protobuf v1.36.5 ) require ( github.com/alecthomas/kong v1.10.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/log v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hack-pad/safejs v0.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nlepage/go-js-promise v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.akshayshah.org/memhttp v0.1.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.14.0 // indirect modernc.org/libc v1.62.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.9.1 // indirect modernc.org/sqlite v1.37.0 // indirect )
-
-
packages/backend/go.sum (deleted)
-
@@ -1,79 +0,0 @@connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= github.com/alecthomas/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw= github.com/alecthomas/kong v1.10.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nlepage/go-js-promise v1.0.0 h1:K7OmJ3+0BgWJ2LfXchg2sI6RDr7AW/KWR8182epFwGQ= github.com/nlepage/go-js-promise v1.0.0/go.mod h1:bdOP0wObXu34euibyK39K1hoBCtlgTKXGc56AGflaRo= github.com/nlepage/go-wasm-http-server/v2 v2.2.1 h1:4tzhSb3HKQ3Ykt2TPfqEnmcPfw8n1E8agv4OzAyckr8= github.com/nlepage/go-wasm-http-server/v2 v2.2.1/go.mod h1:r8j7cEOeUqNp+c+C52sNuWaFTvvT/cNqIwBuEtA36HA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.akshayshah.org/memhttp v0.1.0 h1:Enf7JeZnm+A8iRur0FYvs4ZjWa1VVMc2gG4EirG+aNE= go.akshayshah.org/memhttp v0.1.0/go.mod h1:Q1A5oqQfj2tZFRzpw0HRmmZAMzw8f3AxqOe55Afn1d8= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
-
-
packages/backend/migrations/0001.go (deleted)
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package migrations import ( "database/sql" _ "embed" ) //go:embed 0001.sql var stmts string type Migration001 struct{} func (_ Migration001) Run(tx *sql.Tx) error { _, err := tx.Exec(stmts) return err } func (_ Migration001) Version() uint { return 1 }
-
-
packages/backend/migrations/0001.sql (deleted)
-
@@ -1,26 +0,0 @@-- SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> -- SPDX-License-Identifier: AGPL-3.0-only CREATE TABLE events ( seq INTEGER PRIMARY KEY ON CONFLICT ROLLBACK AUTOINCREMENT, committed_at INTEGER DEFAULT CURRENT_TIMESTAMP, payload BLOB ); CREATE TABLE projections ( id INTEGER PRIMARY KEY AUTOINCREMENT, projection_name TEXT UNIQUE ON CONFLICT ROLLBACK NOT NULL ON CONFLICT ROLLBACK ); CREATE TABLE snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_seq INTEGER NOT NULL ON CONFLICT ROLLBACK, projection_id INTEGER NOT NULL ON CONFLICT ROLLBACK, payload BLOB, FOREIGN KEY(projection_id) REFERENCES projections(id) ON DELETE CASCADE ); CREATE UNIQUE INDEX workspace_projection_index ON snapshots ( event_seq, projection_id );
-
-
packages/backend/migrations/0002.go (deleted)
-
@@ -1,41 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package migrations import ( "database/sql" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" workspaceV1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" ) type Migration002 struct{} func (_ Migration002) Run(tx *sql.Tx) error { workspaceDisplayNameSet, err := proto.Marshal(&eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceV1.Event{ Event: &workspaceV1.Event_WorkspaceDisplayNameSet{ WorkspaceDisplayNameSet: &workspaceV1.WorkspaceDisplayNameSet{ DisplayName: proto.String("名称未設定"), }, }, }, }, }) if err != nil { return err } _, err = tx.Exec("INSERT INTO events (payload) VALUES (?)", workspaceDisplayNameSet) return err } func (_ Migration002) Version() uint { return 2 }
-
-
packages/backend/migrations/migration.go (deleted)
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package migrations import ( "database/sql" ) type Migration interface { Run(tx *sql.Tx) error Version() uint }
-
-
-
@@ -1,61 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package migrations import ( "database/sql" "fmt" "log/slog" ) func Run(db *sql.DB, logger *slog.Logger, migrations []Migration) error { logger.Debug("Starting migration process") var currentVersion uint row := db.QueryRow("PRAGMA user_version") if err := row.Scan(¤tVersion); err != nil { logger.Error("Failed to get current schema version", "error", err) return err } logger.Debug("Got current schema version", "version", currentVersion) for _, m := range migrations { v := m.Version() logger := logger.With("version", v) if v <= currentVersion { logger.Debug("Skipping already applied migration") continue } logger.Debug("Running migration") tx, err := db.Begin() if err != nil { return err } if err := m.Run(tx); err != nil { logger.Error("Failed to run migration", "error", err) return tx.Rollback() } if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", v)); err != nil { logger.Error("Failed to update database schema version", "error", err) return err } currentVersion = v if err := tx.Commit(); err != nil { return err } logger.Info("Completed migration") } logger.Debug("Migration process completed") return nil }
-
-
packages/backend/package.json (deleted)
-
@@ -1,78 +0,0 @@{ "name": "@yamori/backend", "private": true, "type": "module", "main": "sw.js", "dependencies": { "sql.js": "~1.6.2" }, "scripts": { "check": "wireit", "make": "wireit", "clean": "rm backend backend.wasm backend_prelude.js" }, "wireit": { "make:wasm": { "command": "go build -o backend.wasm pocka.jp/x/yamori/backend/wasm", "env": { "GOOS": "js", "GOARCH": "wasm" }, "files": [ "**/*.go", "go.mod", "go.sum", "../../vendor/**/*.go", "migrations/*.sql" ], "dependencies": ["../proto:go"], "output": ["backend.wasm"], "packageLocks": [] }, "make:prelude": { "command": "cp $(go env GOROOT)/lib/wasm/wasm_exec.js ./backend_prelude.js", "output": ["backend_prelude.js"] }, "make:bin": { "command": "go build -o backend pocka.jp/x/yamori/backend/server", "files": ["**/*.go", "go.mod", "go.sum", "migrations/*.sql"], "dependencies": ["../proto:go"], "output": ["backend"], "packageLocks": [] }, "make": { "dependencies": ["make:wasm", "make:bin", "make:prelude"] }, "js": { "files": ["backend_prelude.js"], "dependencies": [ { "script": "wasm", "cascade": false }, { "script": "make:prelude", "cascade": false } ] }, "wasm": { "files": ["backend.wasm"], "dependencies": [ { "script": "make:wasm", "cascade": false } ] }, "bin": { "files": ["backend"], "dependencies": [ { "script": "make:bin", "cascade": false } ] } } }
-
-
packages/backend/package.json.license (deleted)
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/backend/server/main.go (deleted)
-
@@ -1,147 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package main import ( "database/sql" _ "embed" "fmt" "log/slog" "net/http" "os" "strings" "github.com/alecthomas/kong" "github.com/charmbracelet/lipgloss" charmlog "github.com/charmbracelet/log" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/services" _ "modernc.org/sqlite" ) // charmbracelet/log のデフォルトスタイルは見づらく 256 bit 色を利用しているため // ユーザのターミナルパレットを無視した色で出力する。 // この関数はそれをなるべく一般的な色付き CLI の出力にしたロガーを返す。 func charmLogger(options charmlog.Options) *slog.Logger { styles := charmlog.DefaultStyles() styles.Levels = map[charmlog.Level]lipgloss.Style{ charmlog.DebugLevel: lipgloss.NewStyle(). SetString(strings.ToUpper(charmlog.DebugLevel.String())). Bold(true). Width(5). Foreground(lipgloss.ANSIColor(7)), charmlog.InfoLevel: lipgloss.NewStyle(). SetString(strings.ToUpper(charmlog.InfoLevel.String())). Bold(true). Width(5). Foreground(lipgloss.ANSIColor(4)), charmlog.WarnLevel: lipgloss.NewStyle(). SetString(strings.ToUpper(charmlog.WarnLevel.String())). Bold(true). Width(5). Foreground(lipgloss.ANSIColor(3)), charmlog.ErrorLevel: lipgloss.NewStyle(). SetString(strings.ToUpper(charmlog.ErrorLevel.String())). Bold(true). Width(5). Foreground(lipgloss.ANSIColor(1)), charmlog.FatalLevel: lipgloss.NewStyle(). SetString(strings.ToUpper(charmlog.FatalLevel.String())). Bold(true). Width(5). Foreground(lipgloss.ANSIColor(1)), } styles.Timestamp = lipgloss.NewStyle(). Foreground(lipgloss.Color("7")) styles.Key = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(2)) styles.Separator = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(7)) styles.Value = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(5)) logger := charmlog.NewWithOptions(os.Stdout, options) logger.SetStyles(styles) return slog.New(logger) } var cli struct { Help bool `short:"?" help:"Show this message to stdout and exit."` AdminCreationPassword string `help:"Password for creating a user when there is no admin user in workspace"` OverwriteJwtSecret bool `help:"Generate a new JWT signing secret even if there is a configured one"` Verbose bool `help:"Display debug logs?"` Log string `help:"Log format." enum:"text,jsonl" default:"text"` Port uint `short:"p" help:"TCP port to bind." default:"8765"` Host string `short:"h" help:"Network host to bind. Not including port." default:"localhost"` } const ( exit_ok = iota exit_err exit_db_err exit_core_init_err exit_http_server_err ) func run() int { ctx := kong.Parse(&cli, kong.NoDefaultHelp()) if cli.Help { kong.DefaultHelpPrinter(kong.HelpOptions{}, ctx) return exit_ok } logLevel := slog.LevelInfo if cli.Verbose { logLevel = slog.LevelDebug } var logger *slog.Logger if cli.Log == "jsonl" { logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: logLevel, })) } else { logger = charmLogger(charmlog.Options{ ReportTimestamp: true, Level: charmlog.Level(logLevel), }) } db, err := sql.Open("sqlite", ":memory:") if err != nil { logger.Error("Failed to open database", "error", err) return exit_db_err } defer db.Close() core, err := core.New(db, logger) if err != nil { logger.Error("Failed to create core instance", "error", err) return exit_core_init_err } if err := core.Init(cli.AdminCreationPassword, cli.OverwriteJwtSecret); err != nil { logger.Error("Failed to prepare application core", "error", err) return exit_core_init_err } mux := services.Mux(core) addr := fmt.Sprintf("%s:%d", cli.Host, cli.Port) logger.Info("Starting HTTP server", "address", fmt.Sprintf("http://%s", addr)) if err := http.ListenAndServe(addr, h2c.NewHandler(mux, &http2.Server{})); err != nil { logger.Error("Failed to listen and server", "error", err) return exit_http_server_err } return exit_ok } func main() { os.Exit(run()) }
-
-
packages/backend/services/meta/meta.go (deleted)
-
@@ -1,44 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package meta import ( "context" "net/http" "connectrpc.com/connect" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/core" metaV1 "pocka.jp/x/yamori/proto/go/meta/v1" metaV1connect "pocka.jp/x/yamori/proto/go/meta/v1/v1connect" ) type Service struct { core *core.Core } func New(core *core.Core) *Service { return &Service{core: core} } func (s *Service) Ping( ctx context.Context, req *connect.Request[metaV1.PingRequest], ) (*connect.Response[metaV1.PingResponse], error) { _, err := proto.Marshal(req.Msg) if err != nil { return nil, err } res := metaV1.PingResponse{} return connect.NewResponse(&res), nil } func (s *Service) Register(mux *http.ServeMux) { path, handler := metaV1connect.NewMetaServiceHandler(s) mux.Handle(path, handler) }
-
-
packages/backend/services/services.go (deleted)
-
@@ -1,21 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package services import ( "net/http" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/services/meta" "pocka.jp/x/yamori/backend/services/workspace" ) func Mux(core *core.Core) *http.ServeMux { mux := http.NewServeMux() meta.New(core).Register(mux) workspace.New(core).Register(mux) return mux }
-
-
-
@@ -1,257 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "bytes" "context" "crypto/rand" "strings" "connectrpc.com/connect" "github.com/google/uuid" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" "pocka.jp/x/yamori/backend/crypto" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func (s *Service) CreateInitialAdmin( ctx context.Context, req *connect.Request[workspaceV2.CreateInitialAdminRequest], ) (*connect.Response[workspaceV2.CreateInitialAdminResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "CreateInitialAdmin", ) initialAdminPassword := req.Msg.GetInitialAdminPassword() if initialAdminPassword == "" { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("initial_admin_password"), }, }, }), nil } name := req.Msg.GetName() if name == "" { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("name"), }, }, }), nil } if strings.Trim(name, " \r\n\t") != name { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_NameSurroundedBySpaces{ NameSurroundedBySpaces: "Name cannot contain space, CR, LF, Tab", }, }), nil } displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName == "" { displayName = name } password := req.Msg.GetPassword() if password == "" { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("password"), }, }, }), nil } if len(password) <= 8 { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_PasswordLessThanBytes{ PasswordLessThanBytes: 8, }, }), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } defer tx.Rollback() ws, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } pw, err := projection.GetAdminCreationPassword(tx) if err != nil { logger.Error("Failed to read admin_creation_password projection", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := event.UpdateProjections(tx, ws, pw, users); err != nil { logger.Error("Failed to update projections", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if pw.Projection.Password == nil { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_PasswordExpired{ PasswordExpired: &errorV1.AuthenticationError{}, }, }), nil } hash := crypto.HashPassword([]byte(initialAdminPassword), pw.Projection.Password.Salt) if !bytes.Equal(hash, pw.Projection.Password.Hash) { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } for _, u := range users.Projection.Users { if u.GetName() == name { return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_DuplicatedName{ DuplicatedName: name, }, }), nil } } uuid, err := uuid.NewRandom() if err != nil { logger.Error("Failed to generate UUID", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Unable to create new ID"), }, }, }), nil } id := "wu-" + uuid.String() keyID := make([]byte, 32) rand.Read(keyID) err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.ExpireAdminCreationPassword(), workspaceEvent.CreateUser(id, name, displayName, keyID), workspaceEvent.GrantAdminAccess(id), workspaceEvent.ConfigurePasswordLogin(id, password), }) if err != nil { logger.Error("Failed to append events", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := event.UpdateProjections(tx, users); err != nil { logger.Error("Failed to update users projection", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } for _, u := range users.Projection.Users { if u.GetId() == id { logger.Debug("Created an initial admin user", "id", id) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_Ok{ Ok: projectionUserToMessage(u, ws.Projection), }, }), nil } } logger.Error( "Creation of user succeeded, but the user does not exist in projection", "id", id, ) return connect.NewResponse(&workspaceV2.CreateInitialAdminResponse{ Result: &workspaceV2.CreateInitialAdminResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil }
-
-
-
@@ -1,317 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "crypto/rand" "slices" "strings" "connectrpc.com/connect" "github.com/google/uuid" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func createUserSystemError(message string) *connect.Response[workspaceV2.CreateUserResponse] { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func createUserAuthError() *connect.Response[workspaceV2.CreateUserResponse] { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func createUserPermError() *connect.Response[workspaceV2.CreateUserResponse] { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func (s *Service) CreateUser( ctx context.Context, req *connect.Request[workspaceV2.CreateUserRequest], ) (*connect.Response[workspaceV2.CreateUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "CreateUser", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return createUserAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return createUserSystemError("Database error"), nil } defer tx.Rollback() ws, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return createUserSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return createUserSystemError("Database error"), nil } secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return createUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, ws, users, secret); err != nil { logger.Error("Failed to update projections", "error", err) return createUserSystemError("Database error"), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } name := req.Msg.GetName() if name == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("name"), }, }, }), nil } if strings.Trim(name, " \r\n\t") != name { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_NameSurroundedBySpaces{ NameSurroundedBySpaces: "Name cannot contain space, CR, LF, Tab", }, }), nil } displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName == "" { displayName = name } password := req.Msg.GetPassword() if password == "" { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("password"), }, }, }), nil } if len(password) <= 8 { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_PasswordLessThanBytes{ PasswordLessThanBytes: 8, }, }), nil } requiredPerm := types.Permission_PERMISSION_ADD_REGULAR_USER if req.Msg.GetIsAdmin() { requiredPerm = types.Permission_PERMISSION_ADD_ADMIN_USER } if !slices.Contains(user.Permissions, requiredPerm) { return createUserPermError(), nil } for _, u := range users.Projection.Users { if u.GetName() == name { return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_DuplicatedName{ DuplicatedName: name, }, }), nil } } uuid, err := uuid.NewRandom() if err != nil { logger.Error("Failed to generate UUID", "error", err) return createUserSystemError("Unable to issue a new ID"), nil } id := "wu-" + uuid.String() keyID := make([]byte, 32) rand.Read(keyID) events := make([]*eventV1.Event, 0, 2+len(req.Msg.CustomAttributes)) events = append( events, workspaceEvent.CreateUser(id, name, displayName, keyID), workspaceEvent.ConfigurePasswordLogin(id, password), ) for _, attr := range req.Msg.CustomAttributes { events = append( events, workspaceEvent.SetCustomAttribute( id, attr.GetDefinition().GetId().GetValue(), attr.GetValue(), ), ) } if err := event.AppendEvents(tx, events); err != nil { logger.Error("Failed to append user creation events", "error", err) return createUserSystemError("Database error"), nil } if req.Msg.GetIsAdmin() { err := event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.GrantAdminAccess(id), }) if err != nil { logger.Error("Failed to append adming grant events", "error", err) return createUserSystemError("Database error"), nil } } else { permissions := make([]types.Permission, 0, 32) if req.Msg.Permissions != nil { if req.Msg.Permissions.GetCanAddUser() { // ユーザ追加権限はここに来ている時点で持っているためチェックは不要。 permissions = append(permissions, types.Permission_PERMISSION_ADD_REGULAR_USER) } if req.Msg.Permissions.GetCanDeleteRegularUser() { if !slices.Contains(user.Permissions, types.Permission_PERMISSION_DELETE_REGULAR_USER) { return createUserPermError(), nil } permissions = append(permissions, types.Permission_PERMISSION_DELETE_REGULAR_USER) } if req.Msg.Permissions.GetCanReadOtherUserProfile() { if !slices.Contains(user.Permissions, types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE) { return createUserPermError(), nil } if !slices.Contains(user.Permissions, types.Permission_PERMISSION_READ_ADMIN_USER_PROFILE) { return createUserPermError(), nil } permissions = append( permissions, types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE, types.Permission_PERMISSION_READ_ADMIN_USER_PROFILE, ) } if req.Msg.Permissions.GetCanUpdateOtherRegularUserProfile() { if !slices.Contains(user.Permissions, types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE) { return createUserPermError(), nil } permissions = append(permissions, types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE) } if req.Msg.Permissions.GetCanUpdateSelfProfile() { if !slices.Contains(user.Permissions, types.Permission_PERMISSION_UPDATE_SELF_PROFILE) { return createUserPermError(), nil } permissions = append(permissions, types.Permission_PERMISSION_UPDATE_SELF_PROFILE) } if req.Msg.Permissions.GetCanUpdateOtherRegularUserLoginMethod() { if !slices.Contains(user.Permissions, types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD) { return createUserPermError(), nil } permissions = append( permissions, types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD, ) } if req.Msg.Permissions.GetCanUpdateWorkspace() { if !slices.Contains(user.Permissions, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) { return createUserPermError(), nil } permissions = append(permissions, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) } } err := event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.GrantPermission(id, permissions), }) if err != nil { logger.Error("Failed to append a permissions grant event", "error", err) return createUserSystemError("Database error"), nil } } if err := event.UpdateProjections(tx, users); err != nil { logger.Error("Failed to update users projection", "error", err) return createUserSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return createUserSystemError("Database error"), nil } for _, u := range users.Projection.Users { if u.GetId() == id { logger.Debug("Created a new user", "id", id) return connect.NewResponse(&workspaceV2.CreateUserResponse{ Result: &workspaceV2.CreateUserResponse_Ok{ Ok: projectionUserToMessage(u, ws.Projection), }, }), nil } } logger.Error( "Creation of user succeeded, but the user does not exist in projection", "id", id, ) return createUserSystemError("Database error"), nil }
-
-
-
@@ -1,165 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "slices" "connectrpc.com/connect" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func deleteCustomAttributeDefinitionSystemError( message string, ) *connect.Response[workspaceV2.DeleteCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.DeleteCustomAttributeDefinitionResponse{ Result: &workspaceV2.DeleteCustomAttributeDefinitionResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func deleteCustomAttributeDefinitionAuthError() *connect.Response[workspaceV2.DeleteCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.DeleteCustomAttributeDefinitionResponse{ Result: &workspaceV2.DeleteCustomAttributeDefinitionResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func deleteCustomAttributeDefinitionPermError() *connect.Response[workspaceV2.DeleteCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.DeleteCustomAttributeDefinitionResponse{ Result: &workspaceV2.DeleteCustomAttributeDefinitionResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func deleteCustomAttributeDefinitionMissingField(path string) *connect.Response[workspaceV2.DeleteCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.DeleteCustomAttributeDefinitionResponse{ Result: &workspaceV2.DeleteCustomAttributeDefinitionResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String(path), }, }, }) } func (s *Service) DeleteCustomAttributeDefinition( ctx context.Context, req *connect.Request[workspaceV2.DeleteCustomAttributeDefinitionRequest], ) ( *connect.Response[workspaceV2.DeleteCustomAttributeDefinitionResponse], error, ) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "DeleteCustomAttributeDefinition", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return deleteCustomAttributeDefinitionAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return deleteCustomAttributeDefinitionAuthError(), nil } if !slices.Contains(user.Permissions, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) { return deleteCustomAttributeDefinitionPermError(), nil } if req.Msg.Id == nil { return deleteCustomAttributeDefinitionMissingField("id"), nil } id := req.Msg.Id.GetValue() if id == "" { return deleteCustomAttributeDefinitionMissingField("id.value"), nil } for _, def := range workspace.Projection.CustomAttributes { if def.GetId() == id { err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.UndefineCustomAttributeDefinition(id), }) if err != nil { logger.Error( "Failed to append custom attribute undefine event", "error", err, ) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace); err != nil { logger.Error("Failed to update workspace projection", "error", err) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return deleteCustomAttributeDefinitionSystemError("Database error"), nil } return connect.NewResponse(&workspaceV2.DeleteCustomAttributeDefinitionResponse{ Result: &workspaceV2.DeleteCustomAttributeDefinitionResponse_Ok{ Ok: projectionCustomAttributeDefinitionToMessage(def), }, }), nil } } return connect.NewResponse(&workspaceV2.DeleteCustomAttributeDefinitionResponse{ Result: &workspaceV2.DeleteCustomAttributeDefinitionResponse_NotFound{ NotFound: &errorV1.NotFound{ TypeName: proto.String("yamori.workspace.v2.CustomAttributeDefinition"), }, }, }), nil }
-
-
-
@@ -1,179 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "slices" "connectrpc.com/connect" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func deleteUserSystemError(message string) *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func deleteUserAuthError() *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func deleteUserPermError() *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func deleteUserMissingField(path string) *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String(path), }, }, }) } func (s *Service) DeleteUser( ctx context.Context, req *connect.Request[workspaceV2.DeleteUserRequest], ) (*connect.Response[workspaceV2.DeleteUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "DeleteUser", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return deleteUserAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return deleteUserSystemError("Database error"), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return deleteUserSystemError("Database error"), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return deleteUserSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return deleteUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return deleteUserSystemError("Database error"), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return deleteUserAuthError(), nil } if req.Msg.Id == nil { return deleteUserMissingField("id"), nil } id := req.Msg.Id.GetValue() if id == "" { return deleteUserMissingField("id.value"), nil } for _, u := range users.Projection.Users { if u.GetId() == id { requiredPerm := types.Permission_PERMISSION_DELETE_REGULAR_USER if u.GetIsAdmin() { requiredPerm = types.Permission_PERMISSION_DELETE_ADMIN_USER } if !slices.Contains(user.Permissions, requiredPerm) { return deleteUserPermError(), nil } if u.GetIsAdmin() && workspace.Projection.GetNumberOfAdmins() == 1 { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_YouAreTheOnlyAdmin{ YouAreTheOnlyAdmin: req.Msg.Id, }, }), nil } err := event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.DeleteUser(id), }) if err != nil { logger.Error("Failed to append user delete event", "error", err) return deleteUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, users); err != nil { logger.Error("Failed to update workspace and users projection") return deleteUserSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return deleteUserSystemError("Database error"), nil } res := connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_Ok{ Ok: projectionUserToMessage(u, workspace.Projection), }, }) if user.GetId() == id { header := res.Header() core.DeleteTokenFromCookie(&header) } return res, nil } } return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_NotFound{ NotFound: &errorV1.NotFound{ TypeName: proto.String("yamori.workspace.v2.User"), }, }, }), nil }
-
-
-
@@ -1,111 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "connectrpc.com/connect" "google.golang.org/protobuf/proto" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" ) func (s *Service) Get( ctx context.Context, req *connect.Request[workspaceV2.GetRequest], ) (*connect.Response[workspaceV2.GetResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "Get", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := token.Validate(secret); err != nil { logger.Warn("Invalid token found", "error", err) return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } return connect.NewResponse(&workspaceV2.GetResponse{ Result: &workspaceV2.GetResponse_Ok{ Ok: projectionWorkspaceToMessage(workspace.Projection, users.Projection), }, }), nil }
-
-
-
@@ -1,102 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "connectrpc.com/connect" "google.golang.org/protobuf/proto" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" ) func getLoginUserSystemError(message string) *connect.Response[workspaceV2.GetLoginUserResponse] { return connect.NewResponse(&workspaceV2.GetLoginUserResponse{ Result: &workspaceV2.GetLoginUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func getLoginUserAuthError() *connect.Response[workspaceV2.GetLoginUserResponse] { return connect.NewResponse(&workspaceV2.GetLoginUserResponse{ Result: &workspaceV2.GetLoginUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func (s *Service) GetLoginUser( ctx context.Context, req *connect.Request[workspaceV2.GetLoginUserRequest], ) (*connect.Response[workspaceV2.GetLoginUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "GetLoginUser", ) tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return getLoginUserSystemError("Database error"), nil } defer tx.Rollback() ws, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return getLoginUserSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return getLoginUserSystemError("Database error"), nil } secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return getLoginUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, ws, users, secret); err != nil { logger.Error("Failed to update projections", "error", err) return getLoginUserSystemError("Database error"), nil } if len(users.Projection.Users) == 0 { return connect.NewResponse(&workspaceV2.GetLoginUserResponse{ Result: &workspaceV2.GetLoginUserResponse_NoUserInWorkspace{ NoUserInWorkspace: &errorV1.AuthenticationError{}, }, }), nil } header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return getLoginUserAuthError(), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return getLoginUserAuthError(), nil } return connect.NewResponse(&workspaceV2.GetLoginUserResponse{ Result: &workspaceV2.GetLoginUserResponse_Ok{ Ok: projectionUserToMessage(user, ws.Projection), }, }), nil }
-
-
-
@@ -1,116 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "google.golang.org/protobuf/proto" projection "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" ) func projectionUserToMessage(p *projection.Users_User, ws *projection.Workspace) *workspaceV2.User { permissions := workspaceV2.UserPermissions{} for _, perm := range p.Permissions { switch perm { case types.Permission_PERMISSION_ADD_REGULAR_USER: permissions.CanAddUser = proto.Bool(true) case types.Permission_PERMISSION_DELETE_REGULAR_USER: permissions.CanDeleteRegularUser = proto.Bool(true) case types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE: permissions.CanReadOtherUserProfile = proto.Bool(true) case types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE: permissions.CanUpdateOtherRegularUserProfile = proto.Bool(true) case types.Permission_PERMISSION_UPDATE_SELF_PROFILE: permissions.CanUpdateSelfProfile = proto.Bool(true) case types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD: permissions.CanUpdateOtherRegularUserLoginMethod = proto.Bool(true) case types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE: permissions.CanUpdateWorkspace = proto.Bool(true) } } customAttributes := make([]*workspaceV2.CustomAttribute, len(ws.CustomAttributes)) DefLoop: for i, def := range ws.CustomAttributes { for _, attr := range p.CustomAttributes { if def.GetId() == attr.GetId() { customAttributes[i] = &workspaceV2.CustomAttribute{ Definition: &workspaceV2.CustomAttributeDefinition{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: def.Id, }, DisplayName: def.DisplayName, }, Value: attr.Value, } continue DefLoop } } customAttributes[i] = &workspaceV2.CustomAttribute{ Definition: &workspaceV2.CustomAttributeDefinition{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: def.Id, }, DisplayName: def.DisplayName, }, } } return &workspaceV2.User{ Id: &workspaceV2.UserID{ Value: p.Id, }, Name: p.Name, DisplayName: p.DisplayName, LoginMethod: &workspaceV2.LoginMethod{ PasswordConfigured: proto.Bool(p.PasswordLogin != nil), }, IsAdmin: p.IsAdmin, Permissions: &permissions, CustomAttributes: customAttributes, } } func projectionCustomAttributeDefinitionToMessage( p *projection.Workspace_CustomAttribute, ) *workspaceV2.CustomAttributeDefinition { return &workspaceV2.CustomAttributeDefinition{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: p.Id, }, DisplayName: p.DisplayName, } } func projectionWorkspaceToMessage(p *projection.Workspace, u *projection.Users) *workspaceV2.Workspace { users := make([]*workspaceV2.User, len(u.Users)) for i, user := range u.Users { users[i] = projectionUserToMessage(user, p) } customAttributeDefs := make([]*workspaceV2.CustomAttributeDefinition, len(p.CustomAttributes)) for i, def := range p.CustomAttributes { customAttributeDefs[i] = projectionCustomAttributeDefinitionToMessage(def) } return &workspaceV2.Workspace{ DisplayName: p.DisplayName, HasAdmin: proto.Bool(p.GetNumberOfAdmins() > 0), Abbreviations: &workspaceV2.Abbreviations{ Dayoff: p.Abbreviations.DayOff, Worked: p.Abbreviations.Worked, SkipWork: p.Abbreviations.SkipWork, PaidLeave: p.Abbreviations.PaidLeave, }, Users: users, CustomAttributeDefinition: customAttributeDefs, } }
-
-
-
@@ -1,180 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "bytes" "context" "connectrpc.com/connect" "google.golang.org/protobuf/proto" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" "pocka.jp/x/yamori/backend/crypto" ) func (s *Service) Login( ctx context.Context, req *connect.Request[workspaceV2.LoginRequest], ) (*connect.Response[workspaceV2.LoginResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "Login", ) name := req.Msg.GetName() if name == "" { return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("name"), }, }, }), nil } password := req.Msg.GetPassword() if password == "" { return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("password"), }, }, }), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } defer tx.Rollback() users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } ws, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := event.UpdateProjections(tx, ws, users, secret); err != nil { logger.Error("Failed to update projections", "error", err) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } for _, u := range users.Projection.Users { if u.GetName() != name { continue } logger := logger.With("id", u.GetId()) if u.PasswordLogin == nil { logger.Warn("Attempt to login to a user account without password login") return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } hash := crypto.HashPassword([]byte(password), u.PasswordLogin.Salt) if !bytes.Equal(hash, u.PasswordLogin.Hash) { logger.Warn("Login password mismatch") return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil } res := connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_Ok{ Ok: projectionUserToMessage(u, ws.Projection), }, }) token, err := s.core.IssueToken(secret, u) if err != nil { logger.Error("Failed to generate a token", "error", err) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Unable to generate a token"), }, }, }), nil } header := res.Header() token.SaveToCookie(&header) if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } return res, nil } logger.Warn("Attempt to login to a non-existent user account", "name", name) return connect.NewResponse(&workspaceV2.LoginResponse{ Result: &workspaceV2.LoginResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }), nil }
-
-
-
@@ -1,25 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "connectrpc.com/connect" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core" ) func (s *Service) Logout( ctx context.Context, req *connect.Request[workspaceV2.LogoutRequest], ) (*connect.Response[workspaceV2.LogoutResponse], error) { res := connect.NewResponse(&workspaceV2.LogoutResponse{}) header := res.Header() core.DeleteTokenFromCookie(&header) return res, nil }
-
-
-
@@ -1,242 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "slices" "strings" "connectrpc.com/connect" "github.com/google/uuid" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func putCustomAttributeDefinitionSystemError( message string, ) *connect.Response[workspaceV2.PutCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func putCustomAttributeDefinitionAuthError() *connect.Response[workspaceV2.PutCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func putCustomAttributeDefinitionPermError() *connect.Response[workspaceV2.PutCustomAttributeDefinitionResponse] { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func (s *Service) PutCustomAttributeDefinition( ctx context.Context, req *connect.Request[workspaceV2.PutCustomAttributeDefinitionRequest], ) ( *connect.Response[workspaceV2.PutCustomAttributeDefinitionResponse], error, ) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "PutCustomAttributeDefinition", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return putCustomAttributeDefinitionAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return putCustomAttributeDefinitionAuthError(), nil } if !slices.Contains(user.Permissions, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) { return putCustomAttributeDefinitionPermError(), nil } displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName == "" { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String("display_name"), }, }, }), nil } if req.Msg.Id == nil { // 新規作成 uuid, err := uuid.NewRandom() if err != nil { logger.Error("Failed to generate UUID", "error", err) return putCustomAttributeDefinitionSystemError("Unable to issue a new ID"), nil } id := "cf-" + uuid.String() for _, def := range workspace.Projection.CustomAttributes { if def.GetDisplayName() == displayName { return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_DuplicatedDisplayName{ DuplicatedDisplayName: *proto.String(displayName), }, }), nil } } err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.DefineCustomAttributeDefinition(id, displayName), }) if err != nil { logger.Error( "Failed to append custom attribute define event for new one", "error", err, ) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace); err != nil { logger.Error("Failed to update workspace projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } workspace := projectionWorkspaceToMessage(workspace.Projection, users.Projection) for _, def := range workspace.CustomAttributeDefinition { if def.Id.GetValue() == id { logger.Debug("Defined a new custom attribute", "id", id) return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_Ok{ Ok: def, }, }), nil } } logger.Error( "Appending custom attribute definition succeeded, but the new one does not exist in projection", "id", id, ) return putCustomAttributeDefinitionSystemError("Unexpected error"), nil } // 更新 found := false for _, def := range workspace.Projection.CustomAttributes { if def.GetId() == req.Msg.Id.GetValue() { found = true break } } if !found { // TODO: NotFound にする (要 proto 変更) return putCustomAttributeDefinitionSystemError("CustomAttributeDefinition not found"), nil } updateId := req.Msg.Id.GetValue() err = event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.DefineCustomAttributeDefinition(updateId, displayName), }) if err != nil { logger.Error( "Failed to append custom attribute define event for existing one", "error", err, ) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace); err != nil { logger.Error("Failed to update workspace projection", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return putCustomAttributeDefinitionSystemError("Database error"), nil } ws := projectionWorkspaceToMessage(workspace.Projection, users.Projection) for _, def := range ws.CustomAttributeDefinition { if def.Id.GetValue() == updateId { logger.Debug("Updated a custom attribute", "id", updateId) return connect.NewResponse(&workspaceV2.PutCustomAttributeDefinitionResponse{ Result: &workspaceV2.PutCustomAttributeDefinitionResponse_Ok{ Ok: def, }, }), nil } } logger.Error( "Updating custom attribute definition succeeded, but the updated one does not exist in projection", "id", updateId, ) return putCustomAttributeDefinitionSystemError("Unexpected error"), nil }
-
-
-
@@ -1,187 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "slices" "strings" "connectrpc.com/connect" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" workspaceEventV1 "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func updatePermError() *connect.Response[workspaceV2.UpdateResponse] { return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func updateAuthError() *connect.Response[workspaceV2.UpdateResponse] { return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func (s *Service) Update( ctx context.Context, req *connect.Request[workspaceV2.UpdateRequest], ) (*connect.Response[workspaceV2.UpdateResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "Update", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return updateAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return updateAuthError(), nil } if !slices.Contains(user.Permissions, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) { return updatePermError(), nil } events := make([]*eventV1.Event, 0, 2) displayName := strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") if displayName != "" { events = append(events, workspaceEvent.SetDisplayName(displayName)) } if req.Msg.Abbreviations != nil { events = append(events, workspaceEvent.ConfigureAbbreviations(&workspaceEventV1.AbbreviationsConfigured{ DayOff: req.Msg.Abbreviations.Dayoff, Worked: req.Msg.Abbreviations.Worked, SkipWork: req.Msg.Abbreviations.SkipWork, })) } if len(events) == 0 { return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_Ok{ Ok: projectionWorkspaceToMessage(workspace.Projection, users.Projection), }, }), nil } if err := event.AppendEvents(tx, events); err != nil { logger.Error("Failed to append update events", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections (after appending events)", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String("Database error"), }, }, }), nil } return connect.NewResponse(&workspaceV2.UpdateResponse{ Result: &workspaceV2.UpdateResponse_Ok{ Ok: projectionWorkspaceToMessage(workspace.Projection, users.Projection), }, }), nil }
-
-
-
@@ -1,307 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "slices" "strings" "connectrpc.com/connect" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func updateUserSystemError(message string) *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func updateUserAuthError() *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func updateUserMissingField(path string) *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String(path), }, }, }) } func updateUserPermError() *connect.Response[workspaceV2.UpdateUserResponse] { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func (s *Service) UpdateUser( ctx context.Context, req *connect.Request[workspaceV2.UpdateUserRequest], ) (*connect.Response[workspaceV2.UpdateUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "UpdateUser", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return updateUserAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return updateUserSystemError("Database error"), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return updateUserSystemError("Database error"), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return updateUserSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return updateUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return updateUserSystemError("Database error"), nil } loginUser, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return updateUserAuthError(), nil } if req.Msg.Id == nil { return updateUserMissingField("id"), nil } id := req.Msg.Id.GetValue() if id == "" { return updateUserMissingField("id.value"), nil } updateFields := req.Msg.UpdateFields if len(updateFields) == 0 { updateFields = []int32{4, 5, 6, 7} } name := "" if slices.Contains(updateFields, 4) { name = req.Msg.GetName() if strings.Trim(name, " \r\n\t") != name { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_NameSurroundedBySpaces{ NameSurroundedBySpaces: "Name cannot contain space, CR, LF, Tab", }, }), nil } for _, u := range users.Projection.Users { if u.GetId() != id && u.GetName() == name { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_DuplicatedName{ DuplicatedName: name, }, }), nil } } } displayName := "" if slices.Contains(updateFields, 5) { displayName = strings.Trim(req.Msg.GetDisplayName(), " \r\n\t") } for _, u := range users.Projection.Users { if u.GetId() == id { requiredPerm := types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE if u.GetIsAdmin() { requiredPerm = types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE } if id == loginUser.GetId() { requiredPerm = types.Permission_PERMISSION_UPDATE_SELF_PROFILE } if !slices.Contains(loginUser.Permissions, requiredPerm) { return updateUserPermError(), nil } events := make([]*eventV1.Event, 0, 3) if name != "" || displayName != "" { events = append(events, workspaceEvent.UpdateUser(id, name, displayName)) } if slices.Contains(updateFields, 6) { if req.Msg.GetIsAdmin() { if !slices.Contains(loginUser.Permissions, types.Permission_PERMISSION_ADD_ADMIN_USER) { return updateUserPermError(), nil } if !u.GetIsAdmin() { events = append(events, workspaceEvent.GrantAdminAccess(id)) } } else { if u.GetIsAdmin() { if workspace.Projection.GetNumberOfAdmins() == 1 { logger.Warn("Attempt to remove admin role from only admin in the workspace", "userID", id) return updateUserSystemError("This operation results in no admin. Aborted."), nil } events = append(events, workspaceEvent.RevokeAdminAccess(id)) } } } if slices.Contains(updateFields, 7) { permissionUpdateFields := req.Msg.PermissionUpdateFields if len(permissionUpdateFields) == 0 { permissionUpdateFields = []int32{ 1, 2, 3, 4, 5, 6, 7, } } perms := req.Msg.Permissions if perms == nil { perms = &workspaceV2.UserPermissions{} } permissionsToAdd := make([]types.Permission, 0) permissionsToRemove := make([]types.Permission, 0) for _, num := range permissionUpdateFields { switch num { case 1: if perms.GetCanAddUser() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_ADD_REGULAR_USER) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_ADD_REGULAR_USER) } case 2: if perms.GetCanDeleteRegularUser() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_DELETE_REGULAR_USER) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_DELETE_REGULAR_USER) } case 3: if perms.GetCanReadOtherUserProfile() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_READ_REGULAR_USER_PROFILE) } case 4: if perms.GetCanUpdateOtherRegularUserProfile() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_UPDATE_REGULAR_USER_PROFILE) } case 5: if perms.GetCanUpdateSelfProfile() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_UPDATE_SELF_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_UPDATE_SELF_PROFILE) } case 6: if perms.GetCanUpdateOtherRegularUserLoginMethod() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD) } case 7: if perms.GetCanUpdateWorkspace() { permissionsToAdd = append(permissionsToAdd, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) } else { permissionsToRemove = append(permissionsToRemove, types.Permission_PERMISSION_EDIT_WORKSPACE_PROFILE) } } } for _, perm := range permissionsToAdd { if !slices.Contains(loginUser.Permissions, perm) { return updateUserPermError(), nil } } if len(permissionsToAdd) > 0 { events = append(events, workspaceEvent.GrantPermission(id, permissionsToAdd)) } if len(permissionsToRemove) > 0 { events = append(events, workspaceEvent.RevokePermission(id, permissionsToRemove)) } } if err := event.AppendEvents(tx, events); err != nil { logger.Error("Failed to append user update events", "error", err) return updateUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, users); err != nil { logger.Error("Failed to update workspace and users projection") return updateUserSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return updateUserSystemError("Database error"), nil } for _, updated := range users.Projection.Users { if updated.GetId() == id { return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_Ok{ Ok: projectionUserToMessage(updated, workspace.Projection), }, }), nil } } return updateUserSystemError("Updated user not found"), nil } } return connect.NewResponse(&workspaceV2.UpdateUserResponse{ Result: &workspaceV2.UpdateUserResponse_NotFound{ NotFound: &errorV1.NotFound{ TypeName: proto.String("yamori.workspace.v2.User"), }, }, }), nil }
-
-
-
@@ -1,25 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "net/http" "pocka.jp/x/yamori/backend/core" workspaceV2connect "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) type Service struct { core *core.Core } func New(core *core.Core) *Service { return &Service{core: core} } func (s *Service) Register(mux *http.ServeMux) { path, handler := workspaceV2connect.NewWorkspaceServiceHandler(s) mux.Handle(path, handler) }
-
-
-
@@ -1,230 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestCreatesInitialAdmin(t *testing.T) { server := setup(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) res, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ Name: proto.String("alice"), DisplayName: proto.String("Alice"), Password: proto.String("Alice's password"), InitialAdminPassword: proto.String("initial_admin_password"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "alice" { t.Errorf("Expected name=alice, got %s", *v.Ok.Name) } if v.Ok.GetDisplayName() != "Alice" { t.Errorf("Expected display_name=Alice, got %s", *v.Ok.DisplayName) } if v.Ok.Id.GetValue() == "" { t.Error("Expected ID, got empty value") } } func TestSetNameToDisplayName(t *testing.T) { server := setup(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) res, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ Name: proto.String("alice"), Password: proto.String("Alice's password"), InitialAdminPassword: proto.String("initial_admin_password"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "alice" { t.Errorf("Expected display_name=alice, got %s", *v.Ok.DisplayName) } } func TestRejectsSubsequentCreationUsingCreationPassword(t *testing.T) { server := setup(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) aliceRes, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ Name: proto.String("alice"), DisplayName: proto.String("Alice"), Password: proto.String("Alice's password"), InitialAdminPassword: proto.String("initial_admin_password"), }), ) if err != nil { t.Fatal(err) } alice, ok := aliceRes.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(aliceRes.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if alice.Ok.GetName() != "alice" { t.Errorf("Expected name=alice, got %s", *alice.Ok.Name) } bobRes, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("Bob's password"), InitialAdminPassword: proto.String("initial_admin_password"), }), ) if err != nil { t.Fatal(err) } _, ok = bobRes.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_PasswordExpired) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bobRes.Msg.Result)) t.Errorf("Expected password_expired, got %s", typeName.Type().Name()) } } func TestRejectsEmptyName(t *testing.T) { server := setup(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) res, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ DisplayName: proto.String("Alice"), Password: proto.String("Alice's password"), InitialAdminPassword: proto.String("initial_admin_password"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected missing_field_error, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "name" { t.Errorf("Expected path=name, got %s", v.MissingFieldError.GetPath()) } } func TestRejectsEmptyPassword(t *testing.T) { server := setup(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) res, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ Name: proto.String("alice"), DisplayName: proto.String("Alice"), InitialAdminPassword: proto.String("initial_admin_password"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected missing_field_error, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "password" { t.Errorf("Expected path=password, got %s", v.MissingFieldError.GetPath()) } } func TestRejectsInvalidAdminCreationPassword(t *testing.T) { server := setup(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) res, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ Name: proto.String("alice"), DisplayName: proto.String("Alice"), Password: proto.String("Alice's password"), InitialAdminPassword: proto.String("^- w -^"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } }
-
-
-
@@ -1,378 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestCreateUserGetOK(t *testing.T) { server, jar := setupLogin(t) customAttrFoo, err := setupCustomAttributeDefinitions(server, jar, "Foo") if err != nil { t.Fatal(err) } customAttrBar, err := setupCustomAttributeDefinitions(server, jar, "Bar") if err != nil { t.Fatal(err) } httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) res, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), CustomAttributes: []*workspaceV2.CustomAttribute{ { Definition: &workspaceV2.CustomAttributeDefinition{ Id: customAttrBar, }, Value: proto.String("Bob's Bar"), }, }, }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } loginRes, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } l, ok := loginRes.Msg.Result.(*workspaceV2.LoginResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if l.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", l.Ok.GetName()) } if len(l.Ok.CustomAttributes) != 2 { t.Fatalf("Expected 2 custom attributes, got %d", len(l.Ok.CustomAttributes)) } for _, attr := range l.Ok.CustomAttributes { if attr.Definition.Id.GetValue() == customAttrFoo.GetValue() { if attr.Definition.GetDisplayName() != "Foo" { t.Errorf("Expected custom attribute Foo to be named Foo, got %s", attr.Definition.GetDisplayName()) } if attr.GetValue() != "" { t.Errorf("Expected custom attribute Foo to be empty, got %s", attr.GetValue()) } continue } if attr.Definition.Id.GetValue() == customAttrBar.GetValue() { if attr.Definition.GetDisplayName() != "Bar" { t.Errorf("Expected custom attribute Bar to be named Bar, got %s", attr.Definition.GetDisplayName()) } if attr.GetValue() != "Bob's Bar" { t.Errorf("Expected custom attribute Bar to be \"Bob's Bar\", got \"%s\"", attr.GetValue()) } continue } t.Errorf( "Unexpected custom attribute, id=%s, display_name=%s", attr.Definition.Id.GetValue(), attr.Definition.GetDisplayName(), ) } } func TestCreateUserRejectUnauthorizedRequest(t *testing.T) { server, _ := setupLogin(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := res.Msg.Result.(*workspaceV2.CreateUserResponse_AuthenticationError); !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } } func TestCreateByRegularUser(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), CanDeleteRegularUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } carol, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), CanDeleteRegularUser: proto.Bool(true), // bob にはない権限 CanReadOtherUserProfile: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } if _, ok := carol.Msg.Result.(*workspaceV2.CreateUserResponse_PermissionError); !ok { typeName := reflect.Indirect(reflect.ValueOf(carol.Msg.Result)) t.Errorf("Expected permission_error, got %s", typeName.Type().Name()) } dave, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("dave"), DisplayName: proto.String("Dave"), Password: proto.String("dave_password"), Permissions: &workspaceV2.UserPermissions{ // bob の権限と同じ CanAddUser: proto.Bool(true), CanDeleteRegularUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } if _, ok := dave.Msg.Result.(*workspaceV2.CreateUserResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } } func TestCreateUserRejectWithoutPermission(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{}, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } carol, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), Permissions: &workspaceV2.UserPermissions{}, }), ) if err != nil { t.Fatal(err) } if _, ok := carol.Msg.Result.(*workspaceV2.CreateUserResponse_PermissionError); !ok { typeName := reflect.Indirect(reflect.ValueOf(carol.Msg.Result)) t.Errorf("Expected permission_error, got %s", typeName.Type().Name()) } } func TestCreateUserRejectRegularUserCreatesAdminUser(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } carol, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), IsAdmin: proto.Bool(true), Permissions: &workspaceV2.UserPermissions{}, }), ) if err != nil { t.Fatal(err) } if _, ok := carol.Msg.Result.(*workspaceV2.CreateUserResponse_PermissionError); !ok { typeName := reflect.Indirect(reflect.ValueOf(carol.Msg.Result)) t.Errorf("Expected permission_error, got %s", typeName.Type().Name()) } }
-
-
-
@@ -1,313 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestDeleteCustomAttributeDefinitionOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) var fooID string // Create Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } fooID = v.Ok.Id.GetValue() } // Create Bar { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Bar"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } } // Delete Foo { res, err := client.DeleteCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.DeleteCustomAttributeDefinitionRequest{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: proto.String(fooID), }, }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.DeleteCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } } // Get { res, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if len(v.Ok.CustomAttributeDefinition) != 1 { t.Fatalf("Expected 1 custom attribute definition, got %d", len(v.Ok.CustomAttributeDefinition)) } bar := v.Ok.CustomAttributeDefinition[0].GetDisplayName() if bar != "Bar" { t.Fatalf("Expected \"Bar\", got \"%s\"", bar) } } } func TestDeleteCustomAttributeDefinitionUnauthroizedRequest(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.DeleteCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.DeleteCustomAttributeDefinitionRequest{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: proto.String("foo"), }, }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.DeleteCustomAttributeDefinitionResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected authentication_error, got %s", typeName.Type().Name()) } } func TestDeleteCustomAttributeDefinitionPermissionError(t *testing.T) { server, jar := setupRestrictedUserLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) res, err := client.DeleteCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.DeleteCustomAttributeDefinitionRequest{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: proto.String("foo"), }, }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.DeleteCustomAttributeDefinitionResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } } func TestDeleteCustomAttributeDefinitionRejectsMissingID(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) // Create Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } } // Delete (no field) { res, err := client.DeleteCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.DeleteCustomAttributeDefinitionRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.DeleteCustomAttributeDefinitionResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected missing_field, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "id" { t.Fatalf("Expected \"id\", got \"%s\"", v.MissingFieldError.GetPath()) } } // Delete (whitespace only) { res, err := client.DeleteCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.DeleteCustomAttributeDefinitionRequest{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: proto.String(""), }, }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.DeleteCustomAttributeDefinitionResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected missing_field, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "id.value" { t.Fatalf("Expected \"id.value\", got \"%s\"", v.MissingFieldError.GetPath()) } } } func TestDeleteCustomAttributeDefinitionRejectsNonexistentID(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) // Create Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } } // Delete Bar (not found) { res, err := client.DeleteCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.DeleteCustomAttributeDefinitionRequest{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: proto.String("bar"), }, }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.DeleteCustomAttributeDefinitionResponse_NotFound) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected not_found, got %s", typeName.Type().Name()) } if v.NotFound.GetTypeName() != "yamori.workspace.v2.CustomAttributeDefinition" { t.Fatalf( "Expected \"yamori.workspace.v2.CustomAttributeDefinition\", got \"%s\"", v.NotFound.GetTypeName(), ) } } }
-
-
-
@@ -1,379 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestDeleteUserGetOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: created.Ok.Id, }), ) if err != nil { t.Fatal(err) } deleted, ok := deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if deleted.Ok.Id.GetValue() != created.Ok.Id.GetValue() { t.Fatalf("Expected ID=%s, got ID=%s", created.Ok.Id.GetValue(), deleted.Ok.Id.GetValue()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { t.Errorf("User not deleted: ID=%s still exists", created.Ok.Id.GetValue()) } } } func TestDeleteUserInsufficientPermission(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), }), ) created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: created.Ok.Id, }), ) if err != nil { t.Fatal(err) } _, ok = deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { return } } t.Errorf("User deleted: Cannot find ID=%s", created.Ok.Id.GetValue()) } func TestDeleteUserRejectsRegularUserDeleteingAdmin(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) gettingBefore, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } before, ok := gettingBefore.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingBefore.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } var adminID string for _, u := range before.Ok.Users { if u.GetIsAdmin() { adminID = u.Id.GetValue() break } } if adminID == "" { t.Fatal("No admin found: test helper did not create admin user") } bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanDeleteRegularUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: &workspaceV2.UserID{ Value: proto.String(adminID), }, }), ) if err != nil { t.Fatal(err) } _, ok = deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } gettingAfter, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } after, ok := gettingAfter.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingAfter.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range after.Ok.Users { if u.Id.GetValue() == adminID { return } } t.Errorf("User deleted: Cannot find ID=%s", adminID) } func TestDeleteUserRejectsOnlyAdminDeletingSelf(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) gettingBefore, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } before, ok := gettingBefore.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingBefore.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } var adminID string for _, u := range before.Ok.Users { if u.GetIsAdmin() { adminID = u.Id.GetValue() break } } if adminID == "" { t.Fatal("No admin found: test helper did not create admin user") } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: &workspaceV2.UserID{ Value: proto.String(adminID), }, }), ) if err != nil { t.Fatal(err) } _, ok = deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_YouAreTheOnlyAdmin) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected you_are_the_only_admin, got %s", typeName.Type().Name()) } gettingAfter, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } after, ok := gettingAfter.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingAfter.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range after.Ok.Users { if u.Id.GetValue() == adminID { return } } t.Errorf("User deleted: Cannot find ID=%s", adminID) }
-
-
-
@@ -1,84 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestGetLoginUserOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) res, err := client.GetLoginUser( context.Background(), connect.NewRequest(&workspaceV2.GetLoginUserRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.GetLoginUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "alice" { t.Errorf("Expected the login user to be named `alice`, got `%s`", v.Ok.GetName()) } } func TestGetLoginUserNotLoggedIn(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.GetLoginUser( context.Background(), connect.NewRequest(&workspaceV2.GetLoginUserRequest{}), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.GetLoginUserResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } } func TestGetLoginUserNoUsers(t *testing.T) { server := setup(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.GetLoginUser( context.Background(), connect.NewRequest(&workspaceV2.GetLoginUserRequest{}), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.GetLoginUserResponse_NoUserInWorkspace) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected no_user_in_workspace, got %s", typeName.Type().Name()) } }
-
-
-
@@ -1,67 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestGetOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) res, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "名称未設定" { t.Errorf("Expected 名称未設定, got %s", v.Ok.GetDisplayName()) } if v.Ok.GetAbbreviations().GetPaidLeave() == "" { t.Error("Expected default paid_leave, got empty") } } func TestGetRejectUnauthorizedRequest(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } if _, ok := res.Msg.Result.(*workspaceV2.GetResponse_AuthenticationError); !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } }
-
-
-
@@ -1,206 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "database/sql" "fmt" "io" "log/slog" "net/http" "net/http/cookiejar" "reflect" "testing" "connectrpc.com/connect" "go.akshayshah.org/memhttp" "golang.org/x/net/publicsuffix" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/services" _ "modernc.org/sqlite" ) func setup(t *testing.T) *memhttp.Server { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } core, err := core.New(db, logger) if err != nil { t.Fatal(err) } if err := core.Init("initial_admin_password", false); err != nil { t.Fatal(err) } server, err := memhttp.New(services.Mux(core)) if err != nil { t.Fatal(err) } return server } // setupInitialAdmin は ユーザ名 "alice" パスワード "alice_password" の管理者ユーザの存在する // ワークスペースを作成し、接続可能なサーバを返す。 func setupInitialAdmin(t *testing.T) *memhttp.Server { server := setup(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) res, err := client.CreateInitialAdmin( context.Background(), connect.NewRequest(&workspaceV2.CreateInitialAdminRequest{ Name: proto.String("alice"), DisplayName: proto.String("Alice"), Password: proto.String("alice_password"), InitialAdminPassword: proto.String("initial_admin_password"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.CreateInitialAdminResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } return server } // setupLogin は初期管理者を作成し、作成したユーザでログインした状態までを // 設定し、接続可能なサーバとクライアント向けの Cookie を返す。 func setupLogin(t *testing.T) (*memhttp.Server, http.CookieJar) { server := setupInitialAdmin(t) jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) if err != nil { t.Fatal(err) } httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient( httpClient, server.URL(), ) res, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("alice"), Password: proto.String("alice_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := res.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } return server, jar } // setupRestrictedUserLogin は最小権限のユーザを作成し、作成されたユーザで // ログインした状態までを設定した上で接続可能なサーバとクライアント向けの // Cookie を返す。 func setupRestrictedUserLogin(t *testing.T) (*memhttp.Server, http.CookieJar) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{}, }), ) if err != nil { t.Fatal(err) } _, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } return server, jar } // setupCustomAttributeDefinitions はログインされたユーザでカスタムフィールドを // ワークスペース上に定義する。返り値は作成された定義の ID 。 func setupCustomAttributeDefinitions( server *memhttp.Server, jar http.CookieJar, displayName string, ) (*workspaceV2.CustomAttributeDefinitionID, error) { httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) def, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String(displayName), }), ) if err != nil { return nil, err } v, ok := def.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(def.Msg.Result)) return nil, fmt.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != displayName { return nil, fmt.Errorf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } return v.Ok.Id, nil }
-
-
-
@@ -1,96 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestLoginInitialAdmin(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) loginRes, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("alice"), Password: proto.String("alice_password"), }), ) if err != nil { t.Fatal(err) } _, ok := loginRes.Msg.Result.(*workspaceV2.LoginResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(loginRes.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } } func TestLoginRejectIncorrectPassword(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) loginRes, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("alice"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } _, ok := loginRes.Msg.Result.(*workspaceV2.LoginResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(loginRes.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } } func TestLoginRejectIncorrectName(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient( server.Client(), server.URL(), ) loginRes, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("alice_password"), }), ) if err != nil { t.Fatal(err) } _, ok := loginRes.Msg.Result.(*workspaceV2.LoginResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(loginRes.Msg.Result)) t.Errorf("Expected authentication_error, got %s", typeName.Type().Name()) } }
-
-
-
@@ -1,285 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestPutCustomAttributeDefinitionOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) var fooID string // Create Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } fooID = v.Ok.Id.GetValue() } // Create Bar { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Bar"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } } // Update Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ Id: &workspaceV2.CustomAttributeDefinitionID{ Value: proto.String(fooID), }, DisplayName: proto.String("FOO"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "FOO" { t.Fatalf("Expected \"FOO\", got \"%s\"", v.Ok.GetDisplayName()) } } // Get { res, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if len(v.Ok.CustomAttributeDefinition) != 2 { t.Fatalf("Expected 2 custom attribute definition, got %d", len(v.Ok.CustomAttributeDefinition)) } foo := v.Ok.CustomAttributeDefinition[0].GetDisplayName() if foo != "FOO" { t.Fatalf("Expected \"FOO\", got \"%s\"", foo) } bar := v.Ok.CustomAttributeDefinition[1].GetDisplayName() if bar != "Bar" { t.Fatalf("Expected \"Bar\", got \"%s\"", bar) } } } func TestPutCustomAttributeDefinitionUnauthroizedRequest(t *testing.T) { server := setupInitialAdmin(t) client := v2connect.NewWorkspaceServiceClient(server.Client(), server.URL()) res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_AuthenticationError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected authentication_error, got %s", typeName.Type().Name()) } } func TestPutCustomAttributeDefinitionPermissionError(t *testing.T) { server, jar := setupRestrictedUserLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } } func TestPutCustomAttributeDefinitionRejectsMissingDisplayName(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) // Missing field { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected missing_field, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "display_name" { t.Fatalf("Expected \"display_name\", got \"%s\"", v.MissingFieldError.GetPath()) } } // Whitespace only { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String(" \n"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_MissingFieldError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected missing_field, got %s", typeName.Type().Name()) } if v.MissingFieldError.GetPath() != "display_name" { t.Fatalf("Expected \"display_name\", got \"%s\"", v.MissingFieldError.GetPath()) } } } func TestPutCustomAttributeDefinitionRejectsDuplicatedDisplayName(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) // Create Foo { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String("Foo"), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.Ok.GetDisplayName()) } } // Create Foo again { res, err := client.PutCustomAttributeDefinition( context.Background(), connect.NewRequest(&workspaceV2.PutCustomAttributeDefinitionRequest{ DisplayName: proto.String(" Foo "), }), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.PutCustomAttributeDefinitionResponse_DuplicatedDisplayName) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected duplicated_display_name, got %s", typeName.Type().Name()) } if v.DuplicatedDisplayName != "Foo" { t.Fatalf("Expected \"Foo\", got \"%s\"", v.DuplicatedDisplayName) } } }
-
-
-
@@ -1,76 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestUpdateOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) { res, err := client.Update( context.Background(), connect.NewRequest(&workspaceV2.UpdateRequest{ DisplayName: proto.String("Foo Bar"), Abbreviations: &workspaceV2.Abbreviations{ Dayoff: proto.String("DAY_OFF"), }, }), ) if err != nil { t.Fatal(err) } _, ok := res.Msg.Result.(*workspaceV2.UpdateResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } } { res, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } v, ok := res.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(res.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetDisplayName() != "Foo Bar" { t.Errorf("Expected Foo Bar, got %s", v.Ok.GetDisplayName()) } if v.Ok.GetAbbreviations().GetDayoff() != "DAY_OFF" { t.Errorf("Expected dayoff abbreviation to be updated, got %s", v.Ok.GetAbbreviations().GetDayoff()) } if v.Ok.GetAbbreviations().GetPaidLeave() != "年休" { t.Errorf("Expected 年休, got %s", v.Ok.GetAbbreviations().GetPaidLeave()) } } }
-
-
-
@@ -1,390 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestUpdateUserOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: created.Ok.Id, UpdateFields: []int32{5}, Name: proto.String("bobber"), DisplayName: proto.String("Cool Bob"), }), ) if err != nil { t.Fatal(err) } updated, ok := update.Msg.Result.(*workspaceV2.UpdateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if updated.Ok.Id.GetValue() != created.Ok.Id.GetValue() { t.Errorf("Expected ID=%s, got ID=%s", created.Ok.Id.GetValue(), updated.Ok.Id.GetValue()) } if updated.Ok.GetName() != "bob" { t.Errorf("name got changed to \"%s\" even though update_fields not containing it", updated.Ok.GetName()) } if updated.Ok.GetDisplayName() != "Cool Bob" { t.Errorf("display_name did not updated correctly: got=%s", updated.Ok.GetDisplayName()) } } func TestUpdateUserInsufficientPermission(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), }), ) created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: created.Ok.Id, UpdateFields: []int32{5}, DisplayName: proto.String("C"), }), ) if err != nil { t.Fatal(err) } _, ok = update.Msg.Result.(*workspaceV2.UpdateUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { if u.GetDisplayName() != "Carol" { t.Errorf("display_name got updated even on permission_error") } return } } t.Errorf("User deleted: Cannot find ID=%s", created.Ok.Id.GetValue()) } func TestUpdateUserGrantingOverPermission(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), CanUpdateOtherRegularUserProfile: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), }), ) created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: created.Ok.Id, UpdateFields: []int32{7}, Permissions: &workspaceV2.UserPermissions{ CanDeleteRegularUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } _, ok = update.Msg.Result.(*workspaceV2.UpdateUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { if u.GetDisplayName() != "Carol" { t.Errorf("display_name got updated even on permission_error") } return } } t.Errorf("User deleted: Cannot find ID=%s", created.Ok.Id.GetValue()) } func TestUpdateUserRegularUserTriesToChangeToAdmin(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), CanUpdateOtherRegularUserProfile: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } bobLoginResult, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } update, err := client.UpdateUser( context.Background(), connect.NewRequest(&workspaceV2.UpdateUserRequest{ Id: bobLoginResult.Ok.Id, UpdateFields: []int32{6}, IsAdmin: proto.Bool(true), }), ) if err != nil { t.Fatal(err) } _, ok = update.Msg.Result.(*workspaceV2.UpdateUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(update.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == bobLoginResult.Ok.Id.GetValue() { if u.GetIsAdmin() { t.Error("Expected non-admin, got admin") } return } } t.Errorf("User deleted: Cannot find ID=%s", bobLoginResult.Ok.Id.GetValue()) }
-
-
packages/backend/wasm/log.go (deleted)
-
@@ -1,147 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build js && wasm package main import ( "context" "log/slog" "syscall/js" ) type BrowserConsoleLogHandler struct { attrs []slog.Attr group string parent *BrowserConsoleLogHandler } func NewBrowserConsoleLogHandler() *BrowserConsoleLogHandler { return &BrowserConsoleLogHandler{ attrs: nil, group: "", parent: nil, } } func (*BrowserConsoleLogHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } func newObject() js.Value { obj := js.Global().Get("Object").Call("create", js.Null()) return obj } func safeJsValueOf(x slog.Value) js.Value { switch x.Kind() { case slog.KindBool: return js.ValueOf(x.Bool()) case slog.KindInt64: return js.ValueOf(x.Int64()) case slog.KindUint64: return js.ValueOf(x.Uint64()) case slog.KindFloat64: return js.ValueOf(x.Float64()) case slog.KindGroup: group := newObject() for _, g := range x.Group() { group.Set(g.Key, safeJsValueOf(g.Value)) } return group default: return js.ValueOf(x.String()) } } func (h *BrowserConsoleLogHandler) getAttrsObject() (js.Value, js.Value) { var obj js.Value var root js.Value if h.parent == nil { root = newObject() obj = root if h.group != "" { child := newObject() root.Set(h.group, child) obj = child } } else { var parent js.Value parent, root = h.parent.getAttrsObject() obj = parent if h.group != "" { child := newObject() parent.Set(h.group, child) obj = child } } if len(h.attrs) == 0 { return obj, root } for _, attr := range h.attrs { obj.Set(attr.Key, safeJsValueOf(attr.Value)) } return obj, root } func (h *BrowserConsoleLogHandler) Handle(ctx context.Context, record slog.Record) error { console := js.Global().Get("console") var method string switch record.Level { case slog.LevelDebug: method = "debug" case slog.LevelInfo: method = "info" case slog.LevelWarn: method = "warn" case slog.LevelError: method = "error" } message := record.Message + "\n" attrs, attrsRoot := h.getAttrsObject() record.Attrs(func(attr slog.Attr) bool { attrs.Set(attr.Key, safeJsValueOf(attr.Value)) return true }) properties := js.Global().Get("Object").Call("keys", attrsRoot).Get("length") if properties.Equal(js.ValueOf(0)) { console.Call(method, message) } else { console.Call(method, message, attrsRoot) } return nil } func (h *BrowserConsoleLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return &BrowserConsoleLogHandler{ attrs: attrs, group: "", parent: h, } } func (h *BrowserConsoleLogHandler) WithGroup(name string) slog.Handler { if name == "" { return h } return &BrowserConsoleLogHandler{ attrs: nil, group: name, parent: h, } }
-
-
packages/backend/wasm/wasm.go (deleted)
-
@@ -1,53 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build js && wasm package main import ( "database/sql" "log/slog" "os" wasmhttp "github.com/nlepage/go-wasm-http-server/v2" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/services" _ "github.com/matrix-org/go-sqlite3-js" ) func main() { logger := slog.New(NewBrowserConsoleLogHandler()) db, err := sql.Open("sqlite3", "yamori.wasm") if err != nil { logger.Error("Failed to open database", "error", err) os.Exit(2) } core, err := core.New(db, logger) if err != nil { logger.Error("Failed to create core instance", "error", err) os.Exit(3) } if err := core.Init("", false); err != nil { logger.Error("Failed to prepare application core", "error", err) os.Exit(4) } mux := services.Mux(core) _, err = wasmhttp.Serve(mux) if err != nil { logger.Error("Failed to start worker server", "error", err) os.Exit(5) } // サーバがすぐ終了するのを防ぐ。 go-wasm-http-server の README には // 書いていないがサンプル (何故か `docs/` にある) にはしれっと入ってる。 // https://github.com/norunners/vue/issues/40#issuecomment-1253916764 select {} }
-
-
packages/backend/worker.js (deleted)
-
@@ -1,57 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only // // go-wasm-http-server の sw.js を WebWorker で動くようにしたもの。 // I/F を揃えているが、 Go の JS シムに触れているため、 Go や // go-wasm-http-server をアップグレードする際には入念に確認すること。 import initSqlJs from "sql.js"; import sqlJsWasmUrl from "sql.js/dist/sql-wasm.wasm?url"; import "./backend_prelude.js"; const wasmUrl = new URL("./backend.wasm", import.meta.url); async function setup() { const handler = new Promise((resolve) => { self.wasmhttp = { path: "/", setHandler: resolve, }; }); self.addEventListener("message", async (event) => { const req = new Request(event.data.input, event.data.options); const resp = await (await handler)(req); const body = await resp.arrayBuffer(); self.postMessage( { id: event.data.id, body, headers: Object.fromEntries(resp.headers.entries()), }, { transfer: [body], }, ); }); const [wasm, sqlJs] = await Promise.all([ fetch(wasmUrl), initSqlJs({ locateFile: (_file) => sqlJsWasmUrl }), ]); self._go_sqlite = sqlJs; const go = new Go(); go.argv = [wasmUrl]; WebAssembly.instantiateStreaming(wasm, go.importObject).then(({ instance }) => go.run(instance), ); } setup();
-
-
packages/idb_backend/.gitignore (deleted)
-
@@ -1,8 +0,0 @@# このディレクトリ特有の無視設定。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only # What: ビルドされた .js と .d.ts ファイル。 # Why: 編集するものではないため。 /lib
-
-
packages/idb_backend/package.json (deleted)
-
@@ -1,66 +0,0 @@{ "name": "@yamori/idb_backend", "private": true, "type": "module", "scripts": { "check": "wireit", "make": "wireit", "clean": "rm -rf lib" }, "wireit": { "tsconfig": { "files": ["tsconfig.json", "../../tsconfig.jsonc"] }, "make": { "command": "tsc -p tsconfig.build.jsonc", "files": ["src/**/*.ts", "!src/**/*.test.ts", "tsconfig.build.jsonc"], "clean": "if-file-deleted", "output": ["lib/**"], "dependencies": ["tsconfig", "../proto:js", "../proto:dts"], "packageLocks": ["bun.lockb"] }, "js": { "files": ["lib/**/*.js"], "dependencies": [ { "script": "make", "cascade": false } ] }, "dts": { "files": ["lib/**/*.d.ts"], "dependencies": [ { "script": "make", "cascade": false } ] }, "check": { "command": "tsc", "files": ["src/**/*.ts", "package.json"], "output": [], "dependencies": ["../proto:dts", "tsconfig"], "packageLocks": ["bun.lockb"] } }, "exports": { ".": { "types": "./lib/lib.d.ts", "default": "./lib/lib.js" } }, "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@date-fns/tz": "^1.2.0", "@yamori/proto": "workspace:*", "date-fns": "^4.1.0", "idb": "^8.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "fake-indexeddb": "^6.0.0", "typescript": "^5.7.2" } }
-
-
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/idb_backend/src/helpers.test.ts (deleted)
-
@@ -1,191 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, test } from "bun:test"; import { create } from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { isSameBytes, maskMessage, packDate, unpackDate } from "./helpers"; describe("isSameBytes", () => { test("Should return true for same references", () => { const arr = new Uint8Array([0, 1, 2, 3, 4, 5]); expect(isSameBytes(arr, arr)).toBe(true); }); test("Should return true for same bytes", () => { const foo = new Uint8Array([0, 1, 2, 3, 4, 5]); const bar = new Uint8Array([0, 1, 2, 3, 4, 5]); expect(isSameBytes(foo, bar)).toBe(true); }); test("Should return false for different bytes", () => { const foo = new Uint8Array([0, 1, 2, 3, 4, 5]); const bar = new Uint8Array([0, 1, 2, 0, 4, 5]); expect(isSameBytes(foo, bar)).toBe(false); }); test("Should return false for different length bytes", () => { const foo = new Uint8Array([0, 1, 2]); const bar = new Uint8Array([0, 1, 2, 3, 4, 5]); expect(isSameBytes(foo, bar)).toBe(false); }); }); describe("maskMessage", () => { test("Should return every field on empty mask", () => { const masked = maskMessage( DateSchema, { fields: [] }, create(DateSchema, { year: 2000, month: 1, day: 1, }), ); expect(masked.year).toBe(2000); expect(masked.month).toBe(1); expect(masked.day).toBe(1); }); test("Should mask field", () => { const masked = maskMessage( DateSchema, { fields: [DateSchema.field.month.number] }, create(DateSchema, { year: 2000, month: 1, day: 1, }), ); expect(masked.year).toBeEmpty(); expect(masked.month).toBe(1); expect(masked.day).toBeEmpty(); }); test("Should return every field on full mask", () => { const masked = maskMessage( DateSchema, { fields: [ DateSchema.field.year.number, DateSchema.field.month.number, DateSchema.field.day.number, ], }, create(DateSchema, { year: 2000, month: 1, day: 1, }), ); expect(masked.year).toBe(2000); expect(masked.month).toBe(1); expect(masked.day).toBe(1); }); test("Should keep $typeName", () => { const masked = maskMessage( DateSchema, { fields: [DateSchema.field.day.number] }, create(DateSchema, { year: 2000, month: 1, day: 1, }), ); expect(masked.$typeName).not.toBeEmpty(); expect(masked.day).toBe(1); expect(masked.year).toBeEmpty(); expect(masked.month).toBeEmpty(); }); test("Should not mutate input message", () => { const input = create(DateSchema, { year: 2000, month: 1, day: 1, }); maskMessage(DateSchema, { fields: [DateSchema.field.day.number] }, input); expect(input.year).toBe(2000); expect(input.month).toBe(1); expect(input.day).toBe(1); }); }); describe("packDate / unpackDate", () => { test("Should pack and unpack", () => { expect( unpackDate( packDate({ year: 2021, month: 9, day: 31, }), ), ).toEqual({ year: 2021, month: 9, day: 31, }); }); test("Should support far future years", () => { expect( unpackDate( packDate({ year: 3600, month: 2, day: 29, }), ), ).toEqual({ year: 3600, month: 2, day: 29, }); }); test("Should produce sortable numbers", () => { const input = [ { year: 2000, month: 3, day: 1, }, { year: 2000, month: 1, day: 1, }, { year: 2999, month: 1, day: 1, }, { year: 2000, month: 1, day: 2, }, ] as const; expect( input .map(packDate) .sort((a, b) => a - b) .map(unpackDate), ).toEqual([input[1], input[3], input[0], input[2]]); }); });
-
-
packages/idb_backend/src/helpers.ts (deleted)
-
@@ -1,105 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, clearField, type DescMessage, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { TZDate, tz } from "@date-fns/tz"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type DateArg, getYear, getMonth, getDate } from "date-fns"; export const TZ = "Asia/Tokyo"; export function jst(d: MessageInitShape<typeof DateSchema>) { if (typeof d.year !== "number") { throw new Error("Cannot convert year-less date to Date"); } if (typeof d.month !== "number") { throw new Error("Cannot convert month-less date to Date"); } if (typeof d.day !== "number") { throw new Error("Cannot convert day-less date to Date"); } return new TZDate(d.year, d.month - 1, d.day, "Asia/Tokyo"); } export function toProtoDate<DateType extends Date>(d: DateArg<DateType>) { return create(DateSchema, { year: getYear(d, { in: tz(TZ) }), month: getMonth(d, { in: tz(TZ) }) + 1, day: getDate(d, { in: tz(TZ) }), }); } export function isSameBytes(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) { return false; } for (let i = a.length; i >= 0; i--) { if (a[i] !== b[i]) { return false; } } return true; } export function maskMessage< Message extends DescMessage, Data extends MessageInitShape<Message>, >( schema: Message, mask: { fields: readonly number[] }, message: Data, ): MessageShape<Message> { if (!mask.fields.length) { return create(schema, message); } const masked = create(schema, { ...message }); for (const field of schema.fields) { if (!mask.fields.includes(field.number)) { // `create` されたオブジェクトの repeated フィールドを `undefined` にすると // ランタイムでエラーが発生するといった、 Buf が型に見た幻想のツケが回って // くるため `delete` 文や `undefined` 代入は使わずに API を使う。 clearField(masked, field); } } return masked; } export function packDate({ year, month, day, }: MessageInitShape<typeof DateSchema>): number { return ( ((year ?? 0) << 9) | (Math.min(month ?? 0, 12) << 5) | (Math.min(day ?? 1, 31) & 0b11111) ); } export function unpackDate(date: number): MessageInitShape<typeof DateSchema> { return { year: date >> 9, month: (date >> 5) & 0b1111, day: date & 0b11111, }; } export function createRandomBytes(byteLength: number = 16): Uint8Array { const buffer = new Uint8Array(byteLength); self.crypto.getRandomValues(buffer); return buffer; }
-
-
packages/idb_backend/src/lib.ts (deleted)
-
@@ -1,77 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { openDB } from "idb"; import { CONTEXT } from "./symbols"; import { type Context, type RPCMessage, type YamoriDB } from "./types"; import { service } from "./yamori/service"; import { v1 } from "./migrations/v0001"; import { v2 } from "./migrations/v0002"; import { v3 } from "./migrations/v0003"; import { v4 } from "./migrations/v0004"; import { v5 } from "./migrations/v0005"; import { v6 } from "./migrations/v0006"; import { v7 } from "./migrations/v0007"; import { v8 } from "./migrations/v0008"; import { v9 } from "./migrations/v0009"; import { v10 } from "./migrations/v0010"; import { v11 } from "./migrations/v0011"; import { v12 } from "./migrations/v0012"; import { v13 } from "./migrations/v0013"; import { automaticallyProvide } from "./yamori/worker/v1/paid_leave_provision"; class IDBBackend { [CONTEXT]: Context; constructor(ctx: Context) { this[CONTEXT] = ctx; } handle(request: RPCMessage): Promise<Uint8Array> { return service(request, this[CONTEXT]); } } const migrations = [v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13] as const; export async function idbBackend(): Promise<IDBBackend> { const db = await openDB<YamoriDB>("yamori", 13, { async upgrade(db, oldVersion, _newVersion, transaction, _event) { for (let i = 0, l = migrations.length; i < l; i++) { const version = i + 1; const migration = migrations[i]; if (oldVersion < version && migration) { await migration(db, transaction); } } }, }); const tx = db.transaction( ["workers", "paidLeaveProvision", "paidLeaveProvisionTable"], "readwrite", ); const workers = tx.objectStore("workers"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); for await (const worker of workers.iterate()) { await automaticallyProvide({ worker: worker.value, workers, paidLeaveProvisions, paidLeaveProvisionTables, }); } await tx.done; return new IDBBackend({ db, }); }
-
-
-
@@ -1,11 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { IDBPDatabase, IDBPTransaction, StoreNames } from "idb"; import type { YamoriDB } from "../types"; export type Migration = ( db: IDBPDatabase<YamoriDB>, transaction: IDBPTransaction<YamoriDB, StoreNames<YamoriDB>[], "versionchange">, ) => Promise<void>;
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Migration } from "./types"; export const v1: Migration = async (db) => { const workspaces = db.createObjectStore("workspaces", { keyPath: "id", }); workspaces.createIndex("updatedAt", "updatedAt", { unique: false, }); };
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Migration } from "./types"; export const v2: Migration = async (_db, transaction) => { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { const deletionKey = new Uint8Array(16); self.crypto.getRandomValues(deletionKey); const updateKey = new Uint8Array(16); self.crypto.getRandomValues(updateKey); const workerAddKey = new Uint8Array(16); self.crypto.getRandomValues(workerAddKey); await cursor.update({ ...cursor.value, capabilities: { ...cursor.value.capabilities, deletionKey, updateKey, workerAddKey, }, }); } };
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Migration } from "./types"; export const v3: Migration = async (db) => { const workers = db.createObjectStore("workers", { keyPath: "id", }); workers.createIndex("workspaceId", "workspaceId", { unique: false, }); workers.createIndex("updatedAt", "updatedAt", { unique: false, }); };
-
-
-
@@ -1,21 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Migration } from "./types"; export const v4: Migration = async (_db, transaction) => { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { const createLeaveDefinitionKey = new Uint8Array(16); self.crypto.getRandomValues(createLeaveDefinitionKey); await cursor.update({ ...cursor.value, capabilities: { ...cursor.value.capabilities, createLeaveDefinitionKey, }, }); } };
-
-
-
@@ -1,8 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Migration } from "./types"; export const v5: Migration = async () => { // v6 でカバーされる内容だったため削除された。 };
-
-
-
@@ -1,96 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createRandomBytes, packDate } from "../helpers"; import type { Migration } from "./types"; export const v6: Migration = async (_db, transaction) => { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { const defs = cursor.value.leaveDefinitions; await cursor.update({ ...cursor.value, // 労働基準法 第三十九条 第十項 で定義されている「出勤したものとみなす」休業に // ついてはどんな会社でも必ず必須となるため一律で登録する。 leaveDefinitions: [ { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "育児休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "育児休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第七十九号 startAtPackedDate: packDate({ year: 1994, month: 4, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "介護休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "介護休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第百七号 startAtPackedDate: packDate({ year: 1995, month: 10, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, { id: defs.find( (def) => def.createdBy === "system" && def.displayName === "産前産後休業", )?.id || `lv-${self.crypto.randomUUID()}`, displayName: "産前産後休業", updateKey: createRandomBytes(), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 最初からある (法律第四十九号) // 三十九条の施行年月日は調べてもわからなかったので早い方 (遅い方は11/1) // ただぶっちゃけ昔のことだからシステム運用的にはどっちでもいい。 // こんな昔のデータを入れるとしたら付与日数なんかも過去の計算式を用意 // する必要があるため、あくまでも記録・教育的な側面のデータ。 startAtPackedDate: packDate({ year: 1947, month: 9, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, ...defs.filter((def) => def.createdBy !== "system"), ], }); } };
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createRandomBytes } from "../helpers"; import type { Migration } from "./types"; export const v7: Migration = async (_db, transaction) => { const store = transaction.objectStore("workspaces"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, leaveDefinitions: cursor.value.leaveDefinitions.map((def) => { return { ...def, deletionKey: def.createdBy === "user" ? createRandomBytes(16) : undefined, }; }), }); } };
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createRandomBytes } from "../helpers"; import type { Migration } from "./types"; export const v8: Migration = async (db, transaction) => { const store = transaction.objectStore("workers"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, writeWorkRecordKey: cursor.value.writeWorkRecordKey || createRandomBytes(16), }); } const workRecords = db.createObjectStore("workRecords", { keyPath: "recordId", }); workRecords.createIndex( "workspaceId/workerId/datePacked", ["workspaceId", "workerId", "datePacked"], { unique: true, }, ); };
-
-
-
@@ -1,31 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createRandomBytes } from "../helpers"; import type { Migration } from "./types"; export const v9: Migration = async (db, transaction) => { const store = transaction.objectStore("workers"); for await (const cursor of store.iterate()) { await cursor.update({ ...cursor.value, providePaidLeaveKey: cursor.value.providePaidLeaveKey || createRandomBytes(16), }); } const paidLeaveProvision = db.createObjectStore("paidLeaveProvision", { keyPath: "id", }); paidLeaveProvision.createIndex("workspaceId,workerId", ["workspaceId", "workerId"], { unique: false, }); paidLeaveProvision.createIndex( "workspaceId,workerId,providedAtPacked", ["workspaceId", "workerId", "providedAtPacked"], { unique: true }, ); };
-
-
-
@@ -1,95 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { YamoriDB } from "../types"; import type { Migration } from "./types"; export const v10: Migration = async (db, transaction) => { const provisionTables = db.createObjectStore("paidLeaveProvisionTable", { keyPath: "id", }); provisionTables.createIndex("id,workspaceId", ["id", "workspaceId"], { unique: true, }); provisionTables.createIndex("workspaceId", "workspaceId", { multiEntry: true, unique: false, }); const generateID = () => "pt-" + crypto.randomUUID(); const systemTables = [ // https://laws.e-gov.go.jp/law/322AC0000000049#Mp-Ch_4-At_39 { id: generateID(), displayName: "通常", initialRevision: { firstProvisionAmountDays: 10, subsequentProvisionAmountDays: [11, 12, 14, 16, 18, 20], }, revisions: [], workspaceId: "", }, // https://laws.e-gov.go.jp/law/322M40000100023#Mp-At_24_3 { id: generateID(), displayName: "週所定労働日数4日", initialRevision: { firstProvisionAmountDays: 7, subsequentProvisionAmountDays: [8, 9, 10, 12, 13, 15], }, revisions: [], workspaceId: "", }, { id: generateID(), displayName: "週所定労働日数3日", initialRevision: { firstProvisionAmountDays: 5, subsequentProvisionAmountDays: [6, 6, 8, 9, 10, 11], }, revisions: [], workspaceId: "", }, { id: generateID(), displayName: "週所定労働日数2日", initialRevision: { firstProvisionAmountDays: 3, subsequentProvisionAmountDays: [4, 4, 5, 6, 6, 7], }, revisions: [], workspaceId: "", }, { id: generateID(), displayName: "週所定労働日数1日", initialRevision: { firstProvisionAmountDays: 1, subsequentProvisionAmountDays: [2, 2, 2, 3, 3, 3], }, revisions: [], workspaceId: "", }, ] satisfies YamoriDB["paidLeaveProvisionTable"]["value"][]; for (const table of systemTables) { await provisionTables.put(table); } const workspaces = transaction.objectStore("workspaces"); for await (const workspace of workspaces.iterate()) { for (const table of systemTables) { await provisionTables.put({ ...table, id: generateID(), workspaceId: workspace.value.id, baseId: table.id, }); } } };
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Migration } from "./types"; export const v11: Migration = async (_db, transaction) => { const store = transaction.objectStore("workRecords"); for await (const record of store.iterate()) { // @ts-expect-error - record プロパティは削除された。 switch (record.value.record?.type) { case "legal_leave": case "special_leave": store.put({ ...record.value, // @ts-expect-error - record プロパティは削除された。 record: { type: "workspace_defined_leave", // @ts-expect-error - record プロパティは削除された。 leave_id: record.value.record.leave_id, }, }); break; } } };
-
-
-
@@ -1,45 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Migration } from "./types"; export const v12: Migration = async (_db, transaction) => { const store = transaction.objectStore("workspaces"); for await (const ws of store.iterate()) { await store.put({ ...ws.value, leaveDefinitions: ws.value.leaveDefinitions.map((def) => { if (def.createdBy !== "system") { return def; } switch (def.displayName) { case "育児休業": return { ...def, abbrName: "育休", }; case "介護休業": return { ...def, abbrName: "介護", }; case "産前産後休業": return { ...def, abbrName: "産休", }; default: return def; } }), abbreviations: { dayoff: "休日", worked: "出勤", skippedWork: "欠勤", paidLeave: "年休", }, }); } };
-
-
-
@@ -1,109 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only // @ts-nocheck - `workRecords.record` というプロパティは現行のコードでは削除 // されているため、該当部分すべてがエラーとなる。 import type { Migration } from "./types"; export const v13: Migration = async (_db, transaction) => { const workRecords = transaction.objectStore("workRecords"); for await (const cursor of workRecords.iterate()) { const { value } = cursor; if (!value.record || value.kind) { continue; } switch (value.record.type) { case "working_day": { const halvedPL = value.record.timeOffs.find( (t) => t.type === "halved_paid_leave", ); const hourlyPL = value.record.timeOffs.find( (t) => t.type === "hourly_paid_leave", ); if (halvedPL) { await cursor.update({ ...value, record: undefined, kind: { // データとして午前・午後が残っていないのでとりあえず午後に有給休暇を設定する type: "day_halved", am: { type: value.record.hasWorked ? "worked" : "skipped", hourlyPaidLeave: hourlyPL && { hours: hourlyPL.hours, providedAtPacked: hourlyPL.providedAtPacked, }, hourlyWorkspaceDefinedLeaves: [], }, pm: { type: "paid_leave", providedAtPacked: halvedPL.providedAtPacked, }, }, }); } else { await cursor.update({ ...value, record: undefined, kind: { type: "day_whole", data: { type: value.record.hasWorked ? "worked" : "skipped", hourlyPaidLeave: hourlyPL && { hours: hourlyPL.hours, providedAtPacked: hourlyPL.providedAtPacked, }, hourlyWorkspaceDefinedLeaves: [], }, }, }); } break; } case "day_off": { await cursor.update({ ...value, record: undefined, kind: { type: "day_whole", data: { type: "day_off", }, }, }); break; } case "paid_leave": { await cursor.update({ ...value, record: undefined, kind: { type: "day_whole", data: { type: "paid_leave", providedAtPacked: value.record.providedAtPacked, }, }, }); break; } case "workspace_defined_leave": { await cursor.update({ ...value, record: undefined, kind: { type: "day_whole", data: { type: "workspace_defined_leave", leaveId: value.record.leave_id, }, }, }); } } } };
-
-
packages/idb_backend/src/symbols.ts (deleted)
-
@@ -1,4 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export const CONTEXT = Symbol("IDBBackend.Context");
-
-
packages/idb_backend/src/types.ts (deleted)
-
@@ -1,181 +0,0 @@// 外部とのやりとりやメソッドハンドラで共通するオブジェクトの定義。 // // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type DBSchema, type IDBPDatabase } from "idb"; type RecordKind = | { type: "worked"; hourlyPaidLeave?: { hours: number; providedAtPacked?: number; }; hourlyWorkspaceDefinedLeaves: { hours: number; leaveId: string; }[]; } | { type: "skipped"; hourlyPaidLeave?: { hours: number; providedAtPacked?: number; }; hourlyWorkspaceDefinedLeaves: { hours: number; leaveId: string; }[]; } | { type: "day_off"; } | { type: "paid_leave"; providedAtPacked?: number; } | { type: "workspace_defined_leave"; leaveId: string; }; export interface YamoriDB extends DBSchema { workspaces: { key: string; value: { id: string; displayName: string; capabilities: { deletionKey: Uint8Array; updateKey: Uint8Array; workerAddKey: Uint8Array; createLeaveDefinitionKey: Uint8Array; }; abbreviations: { dayoff: string; worked: string; skippedWork: string; paidLeave: string; }; leaveDefinitions: { id: string; displayName: string; abbrName?: string; updateKey: Uint8Array | null; deletionKey?: Uint8Array; createdBy: "user" | "system"; revisions: { id: string; startAtPackedDate: number; snapshot: { isWorkerDeemedToBeWorked: boolean; }; }[]; }[]; updatedAt: number; }; indexes: { updatedAt: number }; }; workers: { key: string; value: { id: string; workspaceId: string; displayName: string; createdAt: number; updatedAt: number; firstProvisionAtPacked?: number; paidLeaveProvisionTableId?: string; writeWorkRecordKey: Uint8Array; providePaidLeaveKey: Uint8Array; lastAutomaticProvisionRanAt?: number; }; indexes: { workspaceId: string; updatedAt: number; }; }; workRecords: { key: string; value: { recordId: string; workerId: string; workspaceId: string; datePacked: number; note?: string; kind?: | { type: "day_whole"; data: RecordKind; } | { type: "day_halved"; am: RecordKind; pm: RecordKind; }; }; indexes: { "workspaceId/workerId/datePacked": [string, string, number]; }; }; paidLeaveProvision: { key: string; value: { id: string; workerId: string; workspaceId: string; providedAtPacked: number; expiresAtPacked: number; amountDays: number; remainingDays: number; note?: string; createdAt: number; updatedAt: number; }; indexes: { "workspaceId,workerId": [string, string]; "workspaceId,workerId,providedAtPacked": [string, string, number]; }; }; paidLeaveProvisionTable: { key: string; value: { id: string; workspaceId?: string; baseId?: string; displayName: string; updateKey?: Uint8Array; initialRevision: { firstProvisionAmountDays: number; subsequentProvisionAmountDays: number[]; }; revisions: { startAtPacked: number; firstProvisionAmountDays: number; subsequentProvisionAmountDays: number[]; }[]; }; indexes: { "id,workspaceId": [string, string]; workspaceId: string; }; }; } export interface Context { db: IDBPDatabase<YamoriDB>; } /** * RPC で授受されるリクエスト・レスポンス。 */ export interface RPCMessage { service: string; method: string; data: Uint8Array; }
-
-
-
@@ -1,162 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { PaidLeaveProvisionTableSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_pb.js"; import { PaidLeaveProvisionTableRevisionSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_revision_pb.js"; import { PaidLeaveProvisionTableReadMaskSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_read_mask_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { unpackDate, packDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; export type CacheStore = Map< string | undefined, YamoriDB["paidLeaveProvisionTable"]["value"][] >; export function createCache(): CacheStore { return new Map(); } export interface GetAllForInput { workspaceId?: string; cache?: CacheStore; store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvisionTable", "readonly" | "readwrite" >; } export async function getAllFor({ workspaceId, cache, store, }: GetAllForInput): Promise<YamoriDB["paidLeaveProvisionTable"]["value"][]> { const hit = cache && cache.get(workspaceId); if (hit) { return hit; } const entries = await store.index("workspaceId").getAll(workspaceId); if (cache) { cache.set(workspaceId, entries); } return entries; } function now(): proto.MessageInitShape<typeof DateSchema> { const d = new Date(); return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), }; } export interface BuildOptions { mask?: proto.MessageInitShape<typeof PaidLeaveProvisionTableReadMaskSchema>; /** * `currentRevision` を選ぶ際の基準日。未指定の場合は現在日。 */ date?: proto.MessageInitShape<typeof DateSchema>; getPaidLeaveProvisionTables( workspaceId?: string, ): Promise<YamoriDB["paidLeaveProvisionTable"]["value"][]>; } export async function build( entry: YamoriDB["paidLeaveProvisionTable"]["value"], { date = now(), getPaidLeaveProvisionTables, mask: { fields = PaidLeaveProvisionTableSchema.fields.map((field) => field.number), baseMask, } = {}, }: BuildOptions, ): Promise<proto.MessageShape<typeof PaidLeaveProvisionTableSchema>> { const init: proto.MessageInitShape<typeof PaidLeaveProvisionTableSchema> = {}; const revisions: proto.MessageInitShape< typeof PaidLeaveProvisionTableRevisionSchema >[] = [ { firstProvisionAmountDays: entry.initialRevision.firstProvisionAmountDays, subsequentProvisionAmountDays: entry.initialRevision.subsequentProvisionAmountDays, }, ...entry.revisions.map((rev) => { return { startAt: unpackDate(rev.startAtPacked), firstProvisionAmountDays: rev.firstProvisionAmountDays, subsequentProvisionAmountDays: rev.subsequentProvisionAmountDays, }; }), ]; for (const field of fields) { switch (field) { case PaidLeaveProvisionTableSchema.field.id.number: init.id = { value: entry.id }; break; case PaidLeaveProvisionTableSchema.field.displayName.number: init.displayName = entry.displayName; break; case PaidLeaveProvisionTableSchema.field.updateKey.number: init.updateKey = entry.updateKey && { key: entry.updateKey }; break; case PaidLeaveProvisionTableSchema.field.revisions.number: init.revisions = revisions; break; case PaidLeaveProvisionTableSchema.field.currentRevision.number: { const match = revisions.findLast((rev) => { if (!rev.startAt) { return true; } return packDate(rev.startAt) >= packDate(date); }); init.currentRevision = match; break; } case PaidLeaveProvisionTableSchema.field.base.number: { const found = (await getPaidLeaveProvisionTables()).find((t) => t.id === entry.baseId) || (entry.workspaceId ? (await getPaidLeaveProvisionTables(entry.workspaceId)).find( (t) => t.id === entry.baseId, ) : undefined); if (!found) { break; } init.base = await build(found, { date, getPaidLeaveProvisionTables, mask: { fields: ( baseMask?.fields ?? PaidLeaveProvisionTableSchema.fields.map((field) => field.number) ).filter( (field) => field !== PaidLeaveProvisionTableSchema.field.base.number, ), }, }); break; } } } return proto.create(PaidLeaveProvisionTableSchema, init); }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Context, type RPCMessage } from "../types"; import { workspaceService } from "./workspace/v1/workspace_service/service"; import { workerService } from "./worker/v1/worker_service/service"; export async function service(request: RPCMessage, ctx: Context): Promise<Uint8Array> { switch (request.service) { case "yamori.workspace.v1.WorkspaceService": return workspaceService(request, ctx); case "yamori.worker.v1.WorkerService": return workerService(request, ctx); default: throw new Error(`Unknown service "${request.service}"`); } }
-
-
-
@@ -1,95 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type LeaveRevisionSchema } from "@yamori/proto/yamori/work_record/v1/leave_revision_pb.js"; import { LeaveReadMaskSchema } from "@yamori/proto/yamori/work_record/v1/leave_read_mask_pb.js"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { packDate, unpackDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; function now(): proto.MessageInitShape<typeof DateSchema> { const d = new Date(); return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), }; } export interface BuildOptions { /** * `currentRevision` を選ぶ際の基準日。未指定の場合は現在日。 */ date?: proto.MessageInitShape<typeof DateSchema>; mask?: proto.MessageInitShape<typeof LeaveReadMaskSchema>; } export function build( entry: YamoriDB["workspaces"]["value"]["leaveDefinitions"][number], { date = now(), mask: { fields = LeaveSchema.fields.map((field) => field.number) } = {}, }: BuildOptions = {}, ): proto.MessageShape<typeof LeaveSchema> { const revisions = entry.revisions.map< proto.MessageInitShape<typeof LeaveRevisionSchema> >((raw) => { return { revisionId: { value: raw.id }, startAt: unpackDate(raw.startAtPackedDate), snapshot: { isWorkerDeemedToBeWorked: raw.snapshot.isWorkerDeemedToBeWorked, }, }; }); const datePacked = packDate(date); let currentRevision: proto.MessageInitShape<typeof LeaveRevisionSchema> | null = null; for (const rev of revisions) { if (typeof rev.startAt === "number" && datePacked >= packDate(rev.startAt)) { currentRevision = rev; } currentRevision = rev; } const init: proto.MessageInitShape<typeof LeaveSchema> = {}; for (const field of fields) { switch (field) { case LeaveSchema.field.id.number: init.id = { value: entry.id }; break; case LeaveSchema.field.displayName.number: init.displayName = entry.displayName; break; case LeaveSchema.field.abbreviationName.number: init.abbreviationName = entry.abbrName || entry.displayName.slice(0, 2); break; case LeaveSchema.field.isWorkerDeemedToBeWorked.number: init.isWorkerDeemedToBeWorked = currentRevision?.snapshot?.isWorkerDeemedToBeWorked; break; case LeaveSchema.field.currentRevision.number: init.currentRevision = currentRevision ?? undefined; break; case LeaveSchema.field.revisions.number: init.revisions = revisions; break; case LeaveSchema.field.deletionKey.number: init.deletionKey = entry.deletionKey && { key: entry.deletionKey }; break; case LeaveSchema.field.updateKey.number: init.updateKey = entry.updateKey ? { key: entry.updateKey } : undefined; break; } } return proto.create(LeaveSchema, init); }
-
-
-
@@ -1,66 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, test } from "bun:test"; import * as proto from "@bufbuild/protobuf"; import { WorkRecordReadMaskSchema } from "@yamori/proto/yamori/work_record/v1/work_record_read_mask_pb.js"; import { packDate } from "../../../helpers"; import { build } from "./work_record"; describe("build", () => { test("Should assign default mask fields even if child mask is present", async () => { expect( build( { recordId: "foo", workspaceId: "ws-foo", workerId: "wr-foo", datePacked: packDate({ year: 2020, month: 1, day: 1, }), kind: { type: "day_whole", data: { type: "worked", hourlyWorkspaceDefinedLeaves: [], }, }, }, { workspace: { id: "ws-foo", displayName: "Foo", capabilities: { updateKey: new Uint8Array([]), deletionKey: new Uint8Array([]), workerAddKey: new Uint8Array([]), createLeaveDefinitionKey: new Uint8Array([]), }, abbreviations: { dayoff: "", worked: "", skippedWork: "", paidLeave: "", }, leaveDefinitions: [], updatedAt: Date.now(), }, mask: proto.create(WorkRecordReadMaskSchema, { workspaceDefinedLeaveMask: {}, }), }, ), ).toMatchObject({ date: { year: 2020, month: 1, day: 1, }, }); }); });
-
-
-
@@ -1,726 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { RecordKindWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_write_input_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; import { WorkRecordBatchWriteMaskSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_mask_pb.js"; import { WorkRecordReadMaskSchema } from "@yamori/proto/yamori/work_record/v1/work_record_read_mask_pb.js"; import { WorkRecordFilterSchema } from "@yamori/proto/yamori/work_record/v1/work_record_filter_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { packDate, unpackDate } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import * as leave from "./leave"; export class NoPaidLeaveAvailableError extends Error { constructor(public readonly at: proto.MessageInitShape<typeof DateSchema>) { super(`No paid leave is available at ${at.year}-${at.month}-${at.day}`); } } export class NoPaidLeaveProvidedAtError extends Error { constructor(public readonly providedAt: proto.MessageInitShape<typeof DateSchema>) { super( `No paid leave was provided at ${providedAt.year}-${providedAt.month}-${providedAt.day}`, ); } } export class InsufficientPaidLeaveRemainingsError extends Error { constructor( public readonly remainingDays: number, public readonly requestedDays: number, ) { super( `Requested ${requestedDays} paid leaves, but only ${remainingDays} days remaining.`, ); } } export class NonexistentLeaveIDError extends Error { constructor( public readonly leaveID: string | undefined, public readonly workspaceID: string, ) { super( `Leave with id="${leaveID}" does not exist in the workspace (workspaceID=${workspaceID})`, ); } } async function createKindWritePayload( targetDatePacked: number, isHalved: boolean, input: proto.MessageShape<typeof RecordKindWriteInputSchema> | undefined, { workspace, getPaidLeaveProvisions, }: Pick<WriteInput, "workspace" | "getPaidLeaveProvisions">, ): Promise< | Extract< NonNullable<YamoriDB["workRecords"]["value"]["kind"]>, { type: "day_whole" } >["data"] | undefined > { const getProvidedAtPacked = async ( targetDatePacked: number, providedAt: proto.MessageShape<typeof DateSchema> | undefined, amount: number = 1, ): Promise<number> => { const paidLeaveProvisions = await getPaidLeaveProvisions(); const packed = providedAt && packDate(providedAt); const provision = packed ? paidLeaveProvisions?.find((provision) => provision.providedAtPacked === packed) : paidLeaveProvisions ?.filter( (provision) => provision.providedAtPacked <= targetDatePacked && provision.expiresAtPacked > targetDatePacked && provision.remainingDays >= amount, ) .sort((a, b) => a.expiresAtPacked - b.expiresAtPacked)[0]; if (!provision) { if (providedAt) { throw new NoPaidLeaveProvidedAtError(providedAt); } throw new NoPaidLeaveAvailableError(unpackDate(targetDatePacked)); } return provision.providedAtPacked; }; switch (input?.kind.case) { case "dayOff": { return { type: "day_off", }; } case "skipped": case "worked": { return { type: input.kind.case === "skipped" ? "skipped" : "worked", hourlyPaidLeave: input.kind.value.hourlyPaidLeave && { hours: input.kind.value.hourlyPaidLeave.hours, // TODO: 時間単位年休に対応 providedAtPacked: await getProvidedAtPacked( targetDatePacked, input.kind.value.hourlyPaidLeave.providedAt, ), }, hourlyWorkspaceDefinedLeaves: input.kind.value.hourlyWorkspaceDefinedLeave.map( (leave) => { const found = workspace.leaveDefinitions.find( (def) => def.id === leave.leaveId?.value, ); if (!found) { throw new NonexistentLeaveIDError(leave.leaveId?.value, workspace.id); } return { hours: leave.hours, leaveId: found.id, }; }, ), }; } case "paidLeave": { return { type: "paid_leave", providedAtPacked: await getProvidedAtPacked( targetDatePacked, input.kind.value.providedAt, isHalved ? 0.5 : 1, ), }; } case "workspaceDefinedLeaveId": { const leaveID = input.kind.value.value; const found = workspace.leaveDefinitions.find((def) => def.id === leaveID); if ( !found || !found.revisions.some((rev) => rev.startAtPackedDate <= targetDatePacked) ) { throw new NonexistentLeaveIDError(leaveID, workspace.id); } return { type: "workspace_defined_leave", leaveId: leaveID, }; } default: return undefined; } } export interface WriteInput { workspace: YamoriDB["workspaces"]["value"]; getPaidLeaveProvisions(): Promise<YamoriDB["paidLeaveProvision"]["value"][]>; mask?: proto.MessageInitShape<typeof WorkRecordBatchWriteMaskSchema>; } export async function write( base: YamoriDB["workRecords"]["value"], message: proto.MessageShape<typeof WorkRecordBatchWriteInputSchema>, { workspace, getPaidLeaveProvisions, mask: { fields = WorkRecordBatchWriteInputSchema.fields.map((field) => field.number), } = {}, }: WriteInput, ): Promise<YamoriDB["workRecords"]["value"]> { const value = { ...base }; let paidLeaveProvisions: YamoriDB["paidLeaveProvision"]["value"][] | null = null; for (const field of fields) { switch (field) { case WorkRecordBatchWriteInputSchema.field.note.number: value.note = message.note; break; case WorkRecordBatchWriteInputSchema.field.dayOff.number: if (message.record.case === "dayOff") { value.kind = { type: "day_whole", data: { type: "day_off", }, }; } else if ( value.kind?.type === "day_whole" && value.kind.data.type === "day_off" ) { value.kind = undefined; } break; case WorkRecordBatchWriteInputSchema.field.workingDay.number: if (message.record.case === "workingDay") { if (!paidLeaveProvisions && message.record.value.timeOffs.length > 0) { paidLeaveProvisions = await getPaidLeaveProvisions(); } const timeOffs = message.record.value.timeOffs .map<{ kind: (typeof message.record.value.timeOffs)[number]["kind"]; provision: YamoriDB["paidLeaveProvision"]["value"]; } | null>((timeOff) => { if (!timeOff.kind.case) { return null; } const providedAtPacked = timeOff.kind.value.providedAt && packDate(timeOff.kind.value.providedAt); const provision = providedAtPacked ? paidLeaveProvisions?.find( (provision) => provision.providedAtPacked === providedAtPacked, ) : paidLeaveProvisions ?.filter( (provision) => provision.providedAtPacked <= value.datePacked && provision.expiresAtPacked > value.datePacked && provision.remainingDays > 0, ) .sort((a, b) => a.expiresAtPacked - b.expiresAtPacked)[0]; if (!provision) { if (timeOff.kind.value.providedAt) { throw new NoPaidLeaveProvidedAtError(timeOff.kind.value.providedAt); } throw new NoPaidLeaveAvailableError(unpackDate(value.datePacked)); } return { kind: timeOff.kind, provision }; }) .filter((t): t is NonNullable<typeof t> => !!t); const halvedPaidLeave = timeOffs.find( ({ kind }) => kind.case === "halvedPaidLeave", ); const hourlyPaidLeave = timeOffs.find( ({ kind }) => kind.case === "hourlyPaidLeave", ); const record: Extract< YamoriDB["workRecords"]["value"]["kind"], { type: "day_whole" } >["data"] = { type: message.record.value.hasWorkerWorked ? "worked" : "skipped", hourlyPaidLeave: hourlyPaidLeave && { hours: ( hourlyPaidLeave.kind as Extract< typeof hourlyPaidLeave.kind, { case: "hourlyPaidLeave" } > ).value.hours, providedAtPacked: hourlyPaidLeave.provision.providedAtPacked, }, hourlyWorkspaceDefinedLeaves: [], }; value.kind = halvedPaidLeave ? { type: "day_halved", am: record, pm: { type: "paid_leave", providedAtPacked: halvedPaidLeave.provision.providedAtPacked, }, } : { type: "day_whole", data: record, }; } else if ( (value.kind?.type === "day_whole" && (value.kind.data.type === "worked" || value.kind.data.type === "skipped")) || (value.kind?.type === "day_halved" && (value.kind.am.type === "worked" || value.kind.pm.type === "skipped") && value.kind.pm.type === "paid_leave") ) { value.kind = undefined; } break; case WorkRecordBatchWriteInputSchema.field.paidLeave.number: if (message.record.case === "paidLeave") { if (!paidLeaveProvisions) { paidLeaveProvisions = await getPaidLeaveProvisions(); } const providedAt = message.record.value.providedAt && packDate(message.record.value.providedAt); const provision = providedAt ? paidLeaveProvisions?.find( (provision) => provision.providedAtPacked === providedAt, ) : paidLeaveProvisions ?.filter( (provision) => provision.providedAtPacked <= value.datePacked && provision.expiresAtPacked > value.datePacked && provision.remainingDays > 0, ) .sort((a, b) => a.expiresAtPacked - b.expiresAtPacked)[0]; if (!provision) { if (message.record.value.providedAt) { throw new NoPaidLeaveProvidedAtError(message.record.value.providedAt); } throw new NoPaidLeaveAvailableError(unpackDate(value.datePacked)); } value.kind = { type: "day_whole", data: { type: "paid_leave", providedAtPacked: provision.providedAtPacked, }, }; } else if ( value.kind?.type === "day_whole" && value.kind.data.type === "paid_leave" ) { value.kind = undefined; } break; case WorkRecordBatchWriteInputSchema.field.workspaceDefinedLeaveId.number: if (message.record.case === "workspaceDefinedLeaveId") { value.kind = { type: "day_whole", data: { type: "workspace_defined_leave", leaveId: message.record.value.value, }, }; } else if ( value.kind?.type === "day_whole" && value.kind.data.type === "workspace_defined_leave" ) { value.kind = undefined; } break; case WorkRecordBatchWriteInputSchema.field.dayWhole.number: if (message.kind.case === "dayWhole") { const data = await createKindWritePayload( value.datePacked, false, message.kind.value, { workspace, async getPaidLeaveProvisions() { if (!paidLeaveProvisions) { paidLeaveProvisions = await getPaidLeaveProvisions(); } return paidLeaveProvisions; }, }, ); value.kind = data && { type: "day_whole", data, }; } else if ( !message.record.case && message.kind.case === undefined && value.kind?.type === "day_whole" ) { value.kind = undefined; } break; case WorkRecordBatchWriteInputSchema.field.dayHalved.number: if (message.kind.case === "dayHalved") { const am = await createKindWritePayload( value.datePacked, true, message.kind.value.am, { workspace, async getPaidLeaveProvisions() { if (!paidLeaveProvisions) { paidLeaveProvisions = await getPaidLeaveProvisions(); } return paidLeaveProvisions; }, }, ); const pm = await createKindWritePayload( value.datePacked, true, message.kind.value.pm, { workspace, async getPaidLeaveProvisions() { if (!paidLeaveProvisions) { paidLeaveProvisions = await getPaidLeaveProvisions(); } return paidLeaveProvisions; }, }, ); value.kind = am && pm && { type: "day_halved", am, pm, }; } else if ( !message.record.case && message.kind.case === undefined && value.kind?.type === "day_halved" ) { value.kind = undefined; } break; } } return value; } export interface GetAllForWorkerInput { workspaceID: string; workerID: string; filter?: proto.MessageInitShape<typeof WorkRecordFilterSchema>; } export async function getAllForWorker( { workerID, workspaceID, filter }: GetAllForWorkerInput, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workRecords", "readonly" | "readwrite" >, ): Promise<YamoriDB["workRecords"]["value"][]> { const now = new Date(); const today = { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), }; const since = packDate(filter?.since ?? today); const until = packDate(filter?.until ?? today); return store .index("workspaceId/workerId/datePacked") .getAll( IDBKeyRange.bound([workspaceID, workerID, since], [workspaceID, workerID, until]), ); } function recordEntryToMessage( entry: Extract< NonNullable<YamoriDB["workRecords"]["value"]["kind"]>, { type: "day_whole" } >["data"], datePacked: number, workspace: YamoriDB["workspaces"]["value"], ): proto.MessageShape<typeof RecordKindSchema> { switch (entry.type) { case "day_off": return proto.create(RecordKindSchema, { kind: { case: "dayOff", value: {}, }, }); case "skipped": case "worked": { return proto.create(RecordKindSchema, { kind: { case: entry.type === "skipped" ? "skipped" : "worked", value: { hourlyPaidLeave: entry.hourlyPaidLeave && { hours: entry.hourlyPaidLeave.hours, providedAt: typeof entry.hourlyPaidLeave.providedAtPacked === "number" ? unpackDate(entry.hourlyPaidLeave.providedAtPacked) : undefined, }, hourlyWorkspaceDefinedLeaves: entry.hourlyWorkspaceDefinedLeaves .map((l) => { const found = workspace.leaveDefinitions.find( (def) => def.id === l.leaveId, ); if (!found) { return null; } return { hours: l.hours, leave: leave.build(found, { date: unpackDate(datePacked), }), }; }) .filter((l): l is NonNullable<typeof l> => !!l), }, }, }); } case "paid_leave": { return proto.create(RecordKindSchema, { kind: { case: "paidLeave", value: { providedAt: typeof entry.providedAtPacked === "number" ? unpackDate(entry.providedAtPacked) : undefined, }, }, }); } case "workspace_defined_leave": { const found = workspace.leaveDefinitions.find((def) => def.id === entry.leaveId); if (!found) { return proto.create(RecordKindSchema); } return proto.create(RecordKindSchema, { kind: { case: "workspaceDefinedLeave", value: leave.build(found, { date: unpackDate(datePacked), }), }, }); } } } export interface BuildInput { workspace: YamoriDB["workspaces"]["value"]; mask?: proto.MessageInitShape<typeof WorkRecordReadMaskSchema>; } export function build( entry: YamoriDB["workRecords"]["value"], { workspace, mask = {} }: BuildInput, ): proto.MessageShape<typeof WorkRecordSchema> { const fields = mask.fields?.length ? mask.fields : WorkRecordSchema.fields.map((field) => field.number); const init: proto.MessageInitShape<typeof WorkRecordSchema> = {}; for (const field of fields) { switch (field) { case WorkRecordSchema.field.date.number: init.date = unpackDate(entry.datePacked); break; case WorkRecordSchema.field.note.number: init.note = entry.note; break; case WorkRecordSchema.field.dayWhole.number: if (entry.kind?.type === "day_whole") { init.kind = { case: "dayWhole", value: recordEntryToMessage(entry.kind.data, entry.datePacked, workspace), }; } break; case WorkRecordSchema.field.dayHalved.number: if (entry.kind?.type === "day_halved") { init.kind = { case: "dayHalved", value: { am: recordEntryToMessage(entry.kind.am, entry.datePacked, workspace), pm: recordEntryToMessage(entry.kind.pm, entry.datePacked, workspace), }, }; } break; case WorkRecordSchema.field.dayOff.number: if (entry.kind?.type === "day_whole" && entry.kind.data.type === "day_off") { init.record = { case: "dayOff", value: {}, }; } break; case WorkRecordSchema.field.paidLeave.number: if (entry.kind?.type === "day_whole" && entry.kind.data.type === "paid_leave") { init.record = { case: "paidLeave", value: { providedAt: typeof entry.kind.data.providedAtPacked === "number" ? unpackDate(entry.kind.data.providedAtPacked) : undefined, }, }; } break; case WorkRecordSchema.field.workingDay.number: if (entry.kind?.type === "day_whole") { if (entry.kind.data.type === "worked" || entry.kind.data.type === "skipped") { init.record = { case: "workingDay", value: { hasWorkerWorked: entry.kind.data.type === "worked", timeOffs: entry.kind.data.hourlyPaidLeave ? [ { kind: { case: "hourlyPaidLeave", value: { hours: entry.kind.data.hourlyPaidLeave.hours, providedAt: typeof entry.kind.data.hourlyPaidLeave.providedAtPacked === "number" ? unpackDate( entry.kind.data.hourlyPaidLeave.providedAtPacked, ) : undefined, }, }, }, ] : [], }, }; } } else if (entry.kind?.type === "day_halved") { if ( (entry.kind.am.type === "worked" || entry.kind.am.type === "skipped") && entry.kind.pm.type === "paid_leave" ) { init.record = { case: "workingDay", value: { hasWorkerWorked: entry.kind.am.type === "worked", timeOffs: entry.kind.am.hourlyPaidLeave ? [ { kind: { case: "halvedPaidLeave", value: { providedAt: typeof entry.kind.pm.providedAtPacked === "number" ? unpackDate(entry.kind.pm.providedAtPacked) : undefined, }, }, }, { kind: { case: "hourlyPaidLeave", value: { hours: entry.kind.am.hourlyPaidLeave.hours, providedAt: typeof entry.kind.am.hourlyPaidLeave.providedAtPacked === "number" ? unpackDate( entry.kind.am.hourlyPaidLeave.providedAtPacked, ) : undefined, }, }, }, ] : [ { kind: { case: "halvedPaidLeave", value: { providedAt: typeof entry.kind.pm.providedAtPacked === "number" ? unpackDate(entry.kind.pm.providedAtPacked) : undefined, }, }, }, ], }, }; } } break; case WorkRecordSchema.field.workspaceDefinedLeave.number: if ( entry.kind?.type === "day_whole" && entry.kind.data.type === "workspace_defined_leave" ) { const id = entry.kind.data.leaveId; const found = workspace.leaveDefinitions.find((def) => def.id === id); init.record = found && { case: "workspaceDefinedLeave", value: leave.build(found, { date: unpackDate(entry.datePacked), mask: mask.workspaceDefinedLeaveMask, }), }; } break; } } return proto.create(WorkRecordSchema, init); }
-
-
-
@@ -1,198 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, setSystemTime, test } from "bun:test"; import { TZDate } from "@date-fns/tz"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { idbBackend } from "../../../lib"; import { toProtoDate, TZ } from "../../../helpers"; import { CONTEXT } from "../../../symbols"; import { createWorkspace } from "../../workspace/v1/workspace_service/_test_utils"; import { createWorker, getWorker } from "./worker_service/_test_utils"; import { automaticallyProvide } from "./paid_leave_provision"; describe("automaticallyProvide", () => { beforeEach(() => { indexedDB = new IDBFactory(); }); afterEach(() => { setSystemTime(); }); test("Should skip workers missing first_paid_leave_provision_at", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, }); const tx = ctx.db.transaction( ["workers", "paidLeaveProvision", "paidLeaveProvisionTable"], "readwrite", ); const workers = tx.objectStore("workers"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); await automaticallyProvide({ worker: (await workers.get(alice.id!.value))!, workers: workers, paidLeaveProvisions, paidLeaveProvisionTables: tx.objectStore("paidLeaveProvisionTable"), }); expect(await paidLeaveProvisions.count()).toBe(0); await tx.done; }); test("Should provide paid leaves", async () => { setSystemTime(new TZDate("2022-06-01", TZ)); const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; // createWorker が自動付与を行うため個別に呼び出す必要はない。 const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, firstPaidLeaveProvisionAt: toProtoDate(new TZDate("2022-01-01", TZ)), }); expect(alice.paidLeaveProvisions).toEqual([ expect.objectContaining({ amountDays: 10, remainingDays: 10, isHalvedDayRemaining: false, providedAt: expect.objectContaining({ year: 2022, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), ]); }); test("Should skip already-provided ones", async () => { setSystemTime(new TZDate("2022-06-01", TZ)); const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, firstPaidLeaveProvisionAt: toProtoDate(new TZDate("2022-01-01", TZ)), }); const tx = ctx.db.transaction( ["workers", "paidLeaveProvision", "paidLeaveProvisionTable"], "readwrite", ); const workers = tx.objectStore("workers"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); setSystemTime(new TZDate("2022-06-02", TZ)); await automaticallyProvide({ worker: (await workers.get(alice.id!.value))!, workers: workers, paidLeaveProvisions, paidLeaveProvisionTables: tx.objectStore("paidLeaveProvisionTable"), }); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: alice.id, readMask: { fields: [WorkerSchema.field.paidLeaveProvisions.number], }, }); expect(after.paidLeaveProvisions).toEqual([ expect.objectContaining({ amountDays: 10, remainingDays: 10, isHalvedDayRemaining: false, providedAt: expect.objectContaining({ year: 2022, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), ]); await tx.done; }); test("Should be able to provide more than one", async () => { setSystemTime(new TZDate("2024-06-01", TZ)); const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const paidLeaveProvisionTableId = workspace.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), )[0]?.id; const alice = await createWorker(ctx, workspace, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId, firstPaidLeaveProvisionAt: toProtoDate(new TZDate("2022-01-01", TZ)), }); expect(alice.paidLeaveProvisions).toHaveLength(3); }); });
-
-
-
@@ -1,377 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { timestampFromMs } from "@bufbuild/protobuf/wkt"; import { tz } from "@date-fns/tz"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { PaidLeaveProvisionInputSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_pb.js"; import { PaidLeaveProvisionInputMaskSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_mask_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { PaidLeaveProvisionReadMaskSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_read_mask_pb.js"; import { PaidLeaveProvisionFilterSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_filter_pb.js"; import { addYears, differenceInYears, subDays, eachYearOfInterval } from "date-fns"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { packDate, unpackDate, jst, toProtoDate, TZ } from "../../../helpers"; import { type YamoriDB } from "../../../types"; import * as workRecord from "../../work_record/v1/work_record"; // TODO: 閏年の 2/29 の 2 年後がどうなるのか確認する function defaultExpiresAt( provided: proto.MessageShape<typeof DateSchema>, ): proto.MessageShape<typeof DateSchema> { const d = new Date(provided.year + 2, provided.month - 1, provided.day); return proto.create(DateSchema, { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), }); } export function init( workspaceID: string, workerID: string, providedAt: proto.MessageShape<typeof DateSchema>, ): YamoriDB["paidLeaveProvision"]["value"] { return { id: crypto.randomUUID(), workerId: workerID, workspaceId: workspaceID, providedAtPacked: packDate(providedAt), expiresAtPacked: packDate(defaultExpiresAt(providedAt)), amountDays: 0, remainingDays: 0, createdAt: Date.now(), updatedAt: Date.now(), }; } export interface AutomaticallyProvideInput { worker: YamoriDB["workers"]["value"]; workers: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workers", "readwrite" >; paidLeaveProvisions: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvision", "readwrite" >; paidLeaveProvisionTables: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvisionTable", "readwrite" >; } export async function automaticallyProvide({ worker, workers, paidLeaveProvisions, paidLeaveProvisionTables, }: AutomaticallyProvideInput): Promise<void> { if ( !worker.paidLeaveProvisionTableId || typeof worker.firstProvisionAtPacked !== "number" ) { // 未設定のためスキップ。 return; } const table = await paidLeaveProvisionTables.get(worker.paidLeaveProvisionTableId); if (!table) { // TODO: Make this error a custom one. throw new Error( "Nonexistent provision table ID: " + worker.paidLeaveProvisionTableId, ); } const firstProvisionAt = jst(unpackDate(worker.firstProvisionAtPacked)); const lookupStart = typeof worker.lastAutomaticProvisionRanAt === "number" ? jst(unpackDate(worker.lastAutomaticProvisionRanAt)) : subDays(firstProvisionAt, 1, { in: tz(TZ) }); const now = new Date(); const intervals = eachYearOfInterval( { start: firstProvisionAt, end: now, }, { in: tz(TZ) }, ).filter((d) => d > lookupStart); if (!intervals.length) { // 最終更新日を書き込んだところで次回のパフォーマンスが変わるわけでも // ないため、DB アクセスを最小にするためスキップする。 return; } for (const date of intervals) { const revision = table.revisions.findLast((rev) => jst(unpackDate(rev.startAtPacked)) >= date) || table.initialRevision; const years = differenceInYears(date, firstProvisionAt, { in: tz(TZ) }); const amountDays = years === 0 || !revision.subsequentProvisionAmountDays.length ? revision.firstProvisionAmountDays : revision.subsequentProvisionAmountDays[ Math.max( 0, Math.min(years - 1, revision.subsequentProvisionAmountDays.length - 1), ) ]!; await paidLeaveProvisions.put({ id: crypto.randomUUID(), workerId: worker.id, workspaceId: worker.workspaceId, providedAtPacked: packDate(toProtoDate(date)), expiresAtPacked: packDate(toProtoDate(addYears(date, 2))), amountDays, remainingDays: amountDays, createdAt: +now, updatedAt: +now, }); } await workers.put({ ...worker, lastAutomaticProvisionRanAt: +now, }); } export function write( base: YamoriDB["paidLeaveProvision"]["value"], message: proto.MessageShape<typeof PaidLeaveProvisionInputSchema>, { fields = PaidLeaveProvisionInputSchema.fields.map((field) => field.number), }: proto.MessageInitShape<typeof PaidLeaveProvisionInputMaskSchema> = {}, ): YamoriDB["paidLeaveProvision"]["value"] { const value = { ...base }; for (const field of fields) { switch (field) { case PaidLeaveProvisionInputSchema.field.note.number: value.note = message.note; break; case PaidLeaveProvisionInputSchema.field.expiresAt.number: if (message.expiresAt) { value.expiresAtPacked = packDate(message.expiresAt); } else if (message.providedAt) { value.expiresAtPacked = packDate(defaultExpiresAt(message.providedAt)); } break; case PaidLeaveProvisionInputSchema.field.amountDays.number: value.amountDays = message.amountDays; value.remainingDays = message.amountDays; break; } } return value; } export interface SyncRemainingsInput { workspaceID: string; workerID: string; paidLeaveProvisions: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvision", "readwrite" >; workRecords: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workRecords", "readonly" | "readwrite" >; } export async function syncRemainings({ paidLeaveProvisions, workspaceID, workerID, workRecords, }: SyncRemainingsInput): Promise<void> { const provisions = await paidLeaveProvisions .index("workspaceId,workerId") .getAll([workspaceID, workerID]); for (const provision of provisions) { const records = await workRecord.getAllForWorker( { workerID, workspaceID, filter: { since: unpackDate(provision.providedAtPacked), until: unpackDate(provision.expiresAtPacked), }, }, workRecords, ); let remainings = provision.amountDays; for (const record of records) { if (!record.kind) { continue; } const kinds = record.kind.type === "day_whole" ? [record.kind.data] : [record.kind.am, record.kind.pm]; for (const kind of kinds) { // TODO: 時間単位年休の対応 if ( kind.type !== "paid_leave" || kind.providedAtPacked !== provision.providedAtPacked ) { continue; } remainings -= record.kind.type === "day_whole" ? 1 : 0.5; } } if (remainings === provision.remainingDays) { continue; } await paidLeaveProvisions.put({ ...provision, remainingDays: remainings, updatedAt: Date.now(), }); } } export interface GetOneByProvidedAtInput { workspaceID: string; workerID: string; providedAt: proto.MessageInitShape<typeof DateSchema>; } export function getOneByprovidedAt( { workerID, workspaceID, providedAt }: GetOneByProvidedAtInput, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvision", "readonly" | "readwrite" >, ): Promise<YamoriDB["paidLeaveProvision"]["value"] | undefined> { return store .index("workspaceId,workerId,providedAtPacked") .get([workspaceID, workerID, packDate(providedAt)]); } export interface GetAllForWorkerInput { workspaceID: string; workerID: string; filter?: proto.MessageInitShape<typeof PaidLeaveProvisionFilterSchema>; } export async function getAllForWorker( { workerID, workspaceID, filter = {} }: GetAllForWorkerInput, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "paidLeaveProvision", "readonly" | "readwrite" >, ): Promise<YamoriDB["paidLeaveProvision"]["value"][]> { const providedAtSince = filter.providedAtSince ? packDate(filter.providedAtSince) : -Infinity; const providedAtUntil = filter.providedAtUntil ? packDate(filter.providedAtUntil) : +Infinity; const expiresAtSince = filter.expiresAtSince ? packDate(filter.expiresAtSince) : -Infinity; const expiresAtUntil = filter.expiresAtUntil ? packDate(filter.expiresAtUntil) : +Infinity; const entries = await store .index("workspaceId,workerId") .getAll([workspaceID, workerID]); return entries .filter((entry) => { return ( entry.providedAtPacked >= providedAtSince && entry.providedAtPacked <= providedAtUntil && entry.expiresAtPacked >= expiresAtSince && entry.expiresAtPacked <= expiresAtUntil ); }) .sort((a, b) => a.expiresAtPacked - b.expiresAtPacked); } export interface BuildInput { mask?: proto.MessageInitShape<typeof PaidLeaveProvisionReadMaskSchema>; } export async function build( entry: YamoriDB["paidLeaveProvision"]["value"], { mask: { fields = PaidLeaveProvisionSchema.fields.map((field) => field.number) } = {}, }: BuildInput, ): Promise<proto.MessageShape<typeof PaidLeaveProvisionSchema>> { const init: proto.MessageInitShape<typeof PaidLeaveProvisionSchema> = {}; for (const field of fields) { switch (field) { case PaidLeaveProvisionSchema.field.providedAt.number: init.providedAt = unpackDate(entry.providedAtPacked); break; case PaidLeaveProvisionSchema.field.expiresAt.number: init.expiresAt = unpackDate(entry.expiresAtPacked); break; case PaidLeaveProvisionSchema.field.amountDays.number: init.amountDays = entry.amountDays; break; case PaidLeaveProvisionSchema.field.note.number: init.note = entry.note; break; case PaidLeaveProvisionSchema.field.createdAt.number: init.createdAt = timestampFromMs(entry.createdAt); break; case PaidLeaveProvisionSchema.field.updatedAt.number: init.updatedAt = timestampFromMs(entry.updatedAt); break; case PaidLeaveProvisionSchema.field.remainingDays.number: init.remainingDays = Math.max(0, Math.floor(entry.remainingDays)); break; case PaidLeaveProvisionSchema.field.isHalvedDayRemaining.number: init.isHalvedDayRemaining = Math.round(entry.remainingDays % 1) === 1; break; } } return proto.create(PaidLeaveProvisionSchema, init); }
-
-
-
@@ -1,58 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { describe, expect, test } from "bun:test"; import * as proto from "@bufbuild/protobuf"; import { WorkerReadMaskSchema } from "@yamori/proto/yamori/worker/v1/worker_read_mask_pb.js"; import { build } from "./worker"; describe("build", () => { test("Should assign default mask fields even if child mask is present", async () => { expect( await build( { id: "wr-foo", displayName: "Foo", workspaceId: "ws-foo", writeWorkRecordKey: new Uint8Array([]), providePaidLeaveKey: new Uint8Array([]), createdAt: Date.now(), updatedAt: Date.now(), }, { workspace: { id: "ws-foo", displayName: "Foo", capabilities: { updateKey: new Uint8Array([]), deletionKey: new Uint8Array([]), workerAddKey: new Uint8Array([]), createLeaveDefinitionKey: new Uint8Array([]), }, abbreviations: { dayoff: "", worked: "", skippedWork: "", paidLeave: "", }, leaveDefinitions: [], updatedAt: Date.now(), }, async getWorkRecords() { return []; }, async getPaidLeaveProvisions() { return []; }, mask: proto.create(WorkerReadMaskSchema, { workRecordsMask: {}, }), }, ), ).toMatchObject({ id: { value: "wr-foo" }, }); }); });
-
-
-
@@ -1,93 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type WorkerReadMaskSchema } from "@yamori/proto/yamori/worker/v1/worker_read_mask_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { type YamoriDB } from "../../../types"; import * as workRecord from "../../work_record/v1/work_record"; import * as paidLeaveProvision from "./paid_leave_provision"; export async function getAllForWorkspace( workspaceID: string, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workers", "readonly" | "readwrite" >, ): Promise<YamoriDB["workers"]["value"][]> { const entries = await store.index("workspaceId").getAll(workspaceID); return entries.sort((a, b) => b.updatedAt - a.updatedAt); } export interface BuildInput { workspace: YamoriDB["workspaces"]["value"]; getWorkRecords( since?: proto.MessageInitShape<typeof DateSchema>, until?: proto.MessageInitShape<typeof DateSchema>, ): Promise<YamoriDB["workRecords"]["value"][]>; getPaidLeaveProvisions(): Promise<YamoriDB["paidLeaveProvision"]["value"][]>; mask?: proto.MessageInitShape<typeof WorkerReadMaskSchema>; } export async function build( entry: YamoriDB["workers"]["value"], { workspace, getWorkRecords, getPaidLeaveProvisions, mask = {} }: BuildInput, ): Promise<proto.MessageShape<typeof WorkerSchema>> { const fields = mask.fields?.length ? mask.fields : WorkerSchema.fields.map((field) => field.number); const init: proto.MessageInitShape<typeof WorkerSchema> = {}; for (const field of fields) { switch (field) { case WorkerSchema.field.id.number: init.id = { value: entry.id }; break; case WorkerSchema.field.displayName.number: init.displayName = entry.displayName; break; case WorkerSchema.field.writeWorkRecordKey.number: init.writeWorkRecordKey = { key: entry.writeWorkRecordKey }; break; case WorkerSchema.field.workRecords.number: { const records = await getWorkRecords(); init.workRecords = records.map((r) => workRecord.build(r, { workspace, mask: mask.workRecordsMask, }), ); break; } case WorkerSchema.field.providePaidLeaveKey.number: init.providePaidLeaveKey = { key: entry.providePaidLeaveKey }; break; case WorkerSchema.field.paidLeaveProvisions.number: { const provisions = await getPaidLeaveProvisions(); init.paidLeaveProvisions = await Promise.all( provisions.map((p) => paidLeaveProvision.build(p, { mask: mask.paidLeaveProvisionsMask, }), ), ); break; } } } return proto.create(WorkerSchema, init); }
-
-
-
@@ -1,120 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { expect } from "bun:test"; import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { ProvidePaidLeaveRequestSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_request_pb.js"; import { ProvidePaidLeaveResponseSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_response_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type Context } from "../../../../types"; import { workerService } from "./service"; export async function createWorker( ctx: Context, workspace: MessageInitShape<typeof WorkspaceSchema>, payload: MessageInitShape<typeof CreateRequestSchema> = { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Bar", }, ): Promise<MessageShape<typeof WorkerSchema>> { const resp = fromBinary( CreateResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "Create", data: toBinary(CreateRequestSchema, create(CreateRequestSchema, payload)), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for worker creation, got ${JSON.stringify(resp.result.case)}`, ); } if (!resp.result.value.worker) { expect.unreachable( `Expected Create method to return a created worker, got empty value`, ); } return resp.result.value.worker; } export async function getWorker( ctx: Context, payload: MessageInitShape<typeof GetRequestSchema>, ): Promise<MessageShape<typeof WorkerSchema>> { const resp = fromBinary( GetResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "Get", data: toBinary(GetRequestSchema, create(GetRequestSchema, payload)), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for worker retrieve, got ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; } export async function providePaidProvision( ctx: Context, payload: MessageInitShape<typeof ProvidePaidLeaveRequestSchema>, ): Promise<MessageShape<typeof PaidLeaveProvisionSchema>> { const resp = fromBinary( ProvidePaidLeaveResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "ProvidePaidLeave", data: toBinary( ProvidePaidLeaveRequestSchema, create(ProvidePaidLeaveRequestSchema, payload), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for paid leave provision, got ${JSON.stringify(resp.result.case)}`, ); } if (!resp.result.value.paidLeaveProvision) { expect.unreachable( `Expected ProvidePaidLeave method to return a written provision, got empty value`, ); } return resp.result.value.paidLeaveProvision; }
-
-
-
@@ -1,299 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { CreateRequestSchema as WorkspaceCreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema as WorkspaceCreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { create as method } from "./create"; import { create as createWorkspace } from "../../../workspace/v1/workspace_service/create"; beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return an error on DB error", async () => { const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: { value: "ws-foo", }, displayName: "Alice", workerAddKey: { key: new Uint8Array([]) }, }), ), { db: await openDB("test"), }, ), ); if (resp.result.case !== "systemError") { expect.unreachable(); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); async function createTestWorkspace(ctx: Context): Promise<Workspace> { const created = fromBinary( WorkspaceCreateResponseSchema, await createWorkspace( toBinary( WorkspaceCreateRequestSchema, create(WorkspaceCreateRequestSchema, { displayName: "Test Workspace", }), ), ctx, ), ); if (created.result.case !== "ok") { expect.unreachable(`Failed to create workspace: result.case=${created.result.case}`); } if (!created.result.value.workspace) { expect.unreachable("Failed to create workspace: workspace is empty."); } return created.result.value.workspace; } test("Should return an error if display_name is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id!, workerAddKey: workspace.workerAddKey!, }), ), ctx, ), ); if (resp.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found ${resp.result.case}`); } expect(resp.result.value.path).toBe("display_name"); }); test("Should return an error if workspace_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workerAddKey: workspace.workerAddKey!, displayName: "Alice", }), ), ctx, ), ); if (resp.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found ${resp.result.case}`); } expect(resp.result.value.path).toBe("workspace_id"); }); test("Should return an error if worker_add_key is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id!, displayName: "Alice", }), ), ctx, ), ); if (resp.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found ${resp.result.case}`); } expect(resp.result.value.path).toBe("worker_add_key"); }); test("Should return an error if target workspace does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: { value: "ws-foo" }, workerAddKey: { key: new Uint8Array([]) }, displayName: "Alice", }), ), ctx, ), ); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found ${resp.result.case}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return not_found for nonexistent provision table ID", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, displayName: "Alice", paidLeaveProvisionTableId: { value: "pt-sys-not-found", }, }), ), ctx, ), ); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found ${resp.result.case}`); } expect(resp.result.value.typeName).toBe( "yamori.paid_leave_provision.v1.PaidLeaveProvisionTable", ); }); test("Should return an error for capability mismatch", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id!, workerAddKey: { key: new Uint8Array([]) }, displayName: "Alice", }), ), ctx, ), ); if (resp.result.case !== "capabilityError") { expect.unreachable(`Expected "capability_error", found ${resp.result.case}`); } expect(resp.result.value.path).toBe("worker_add_key"); }); test("Should add a worker entry", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id!, workerAddKey: workspace.workerAddKey!, displayName: "Alice", }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${resp.result.case}`); } expect(resp.result.value.worker?.id?.value).not.toBeEmpty(); expect(resp.result.value.worker?.displayName).toBe("Alice"); }); test("Should mask fields", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id!, workerAddKey: workspace.workerAddKey!, displayName: "Alice", readMask: { fields: [WorkerSchema.field.id.number], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${resp.result.case}`); } expect(resp.result.value.worker?.id?.value).not.toBeEmpty(); expect(resp.result.value.worker?.displayName).toBeEmpty(); });
-
-
-
@@ -1,235 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create as createMessage, type MessageInitShape, fromBinary, toBinary, } from "@bufbuild/protobuf"; import { CreateRequestSchema, type CreateRequest, } from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { isSameBytes, createRandomBytes, packDate } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude< NonNullable<MessageInitShape<typeof CreateResponseSchema>["result"]>, { case: undefined } >, ): Uint8Array { return toBinary(CreateResponseSchema, createMessage(CreateResponseSchema, { result })); } export async function create(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: CreateRequest; try { req = fromBinary(CreateRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } if (!req.displayName) { return respond({ case: "missingField", value: { path: "display_name", }, }); } if (!req.workspaceId) { return respond({ case: "missingField", value: { path: "workspace_id", }, }); } if (!req.workerAddKey) { return respond({ case: "missingField", value: { path: "worker_add_key", }, }); } // TODO: Handle idempotency_key (how?) let tx; let ws; try { tx = ctx.db.transaction( [ "workspaces", "workers", "workRecords", "paidLeaveProvision", "paidLeaveProvisionTable", ], "readwrite", ); ws = await tx.objectStore("workspaces").get(req.workspaceId.value); if (!ws) { return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } if (!isSameBytes(req.workerAddKey.key, ws.capabilities.workerAddKey)) { return respond({ case: "capabilityError", value: { path: "worker_add_key", }, }); } } catch (error) { tx?.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during workspace lookup" : `Exception thrown during workspace lookup: ${error}`, }, }); } if (req.paidLeaveProvisionTableId) { try { const store = tx.objectStore("paidLeaveProvisionTable"); const found = await store.get(req.paidLeaveProvisionTableId.value); if ( !found || (typeof found.workspaceId === "string" && found.workspaceId !== ws.id) ) { return respond({ case: "notFound", value: { typeName: "yamori.paid_leave_provision.v1.PaidLeaveProvisionTable", }, }); } } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during provision table ID check" : `Exception thrown during provision table ID check: ${error}`, }, }); } } const id = "wr-" + crypto.randomUUID(); try { const store = tx.objectStore("workers"); const addedKey = await store.add({ id, workspaceId: req.workspaceId.value, displayName: req.displayName, createdAt: Date.now(), updatedAt: Date.now(), writeWorkRecordKey: createRandomBytes(16), providePaidLeaveKey: createRandomBytes(16), firstProvisionAtPacked: req.firstPaidLeaveProvisionAt && packDate(req.firstPaidLeaveProvisionAt), paidLeaveProvisionTableId: req.paidLeaveProvisionTableId?.value, }); const added = await store.get(addedKey); if (!added) { throw "Failed to query an added entry"; } const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); await paidLeaveProvision.automaticallyProvide({ worker: added, workers: store, paidLeaveProvisions, paidLeaveProvisionTables: tx.objectStore("paidLeaveProvisionTable"), }); const value = await worker.build(added, { workspace: ws, getWorkRecords() { return workRecord.getAllForWorker( { workerID: id, workspaceID: ws.id, }, tx.objectStore("workRecords"), ); }, async getPaidLeaveProvisions() { return paidLeaveProvision.getAllForWorker( { workspaceID: ws.id, workerID: id, }, paidLeaveProvisions, ); }, mask: req.readMask, }); await tx.done; return respond({ case: "ok", value: { worker: value, }, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to create a new worker record" : `Exception thrown during inserting worker: ${error}`, }, }); } }
-
-
-
@@ -1,184 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageInitShape, type MessageShape, } from "@bufbuild/protobuf"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { createWorkspace } from "../../../workspace/v1/workspace_service/_test_utils"; import { createWorker } from "./_test_utils"; import { workerService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function getWorker( ctx: Context, request: MessageInitShape<typeof GetRequestSchema>, ): Promise<MessageShape<typeof GetResponseSchema>> { return fromBinary( GetResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "Get", data: toBinary(GetRequestSchema, create(GetRequestSchema, request)), }, ctx, ), ); } test("Should return missing_field if workspace_id is missing", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await getWorker(ctx, { workerId: worker.id, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("workspace_id"); }); test("Should return missing_field if worker_id is missing", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); await createWorker(ctx, workspace); const resp = await getWorker(ctx, { workspaceId: workspace.id, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("worker_id"); }); test("Should return not_found if workspace does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await getWorker(ctx, { workspaceId: { value: workspace.id?.value + "-000", }, workerId: worker.id, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "notFound", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return not_found if worker does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await getWorker(ctx, { workspaceId: workspace.id, workerId: { value: worker.id?.value + "-000", }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "notFound", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.Worker"); }); test("Should return not_found for workers from another workspace", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace(ctx); const bar = await createWorkspace(ctx); const worker = await createWorker(ctx, foo); const resp = await getWorker(ctx, { workspaceId: bar.id, workerId: worker.id, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "notFound", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.Worker"); }); test("Should return worker", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.id).toEqual(worker.id!); expect(resp.result.value.displayName).toEqual(worker.displayName); }); test("Should mask fields", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, readMask: { fields: [WorkerSchema.field.displayName.number], }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.id).toBeEmpty(); expect(resp.result.value.displayName).toEqual(worker.displayName); });
-
-
-
@@ -1,160 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape, type MessageShape, } from "@bufbuild/protobuf"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { type Context } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude< NonNullable<MessageInitShape<typeof GetResponseSchema>["result"]>, { case: undefined } >, ): Uint8Array { return toBinary(GetResponseSchema, create(GetResponseSchema, { result })); } export async function get(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: MessageShape<typeof GetRequestSchema>; try { req = fromBinary(GetRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } const { workspaceId, workerId } = req; if (!workspaceId) { return respond({ case: "missingField", value: { path: "workspace_id", }, }); } if (!workerId) { return respond({ case: "missingField", value: { path: "worker_id", }, }); } let tx; let ws; try { tx = ctx.db.transaction( ["workspaces", "workers", "workRecords", "paidLeaveProvision"], "readonly", ); ws = await tx.objectStore("workspaces").get(workspaceId.value); if (!ws) { return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } } catch (error) { tx?.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during workspace lookup" : `Exception thrown during workspace lookup: ${error}`, }, }); } try { const found = await tx.objectStore("workers").get(workerId.value); if (!found || found.workspaceId !== ws.id) { return respond({ case: "notFound", value: { typeName: "yamori.worker.v1.Worker", }, }); } const value = await worker.build(found, { workspace: ws, getWorkRecords(since, until) { return workRecord.getAllForWorker( { workspaceID: workspaceId.value, workerID: workerId.value, filter: since && until ? { since, until, } : req.workRecordFilter, }, tx.objectStore("workRecords"), ); }, getPaidLeaveProvisions() { return paidLeaveProvision.getAllForWorker( { workspaceID: workspaceId.value, workerID: workerId.value, filter: req.paidLeaveProvisionFilter, }, tx.objectStore("paidLeaveProvision"), ); }, mask: req.readMask, }); await tx.done; return respond({ case: "ok", value, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during worker lookup" : `Exception thrown during worker lookup: ${error}`, }, }); } }
-
-
-
@@ -1,303 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageInitShape } from "@bufbuild/protobuf"; import { CreateRequestSchema as WorkspaceCreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema as WorkspaceCreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { CreateRequestSchema } from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { WorkerSchema, type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { openDB } from "idb"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { create as createWorkspace } from "../../../workspace/v1/workspace_service/create"; import { create as createWorker } from "./create"; import { list } from "./list"; beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return an error on DB error", async () => { const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { workspaceId: { value: "ws-foo" }, }), ), { db: await openDB("test") }, ), ); if (resp.result.case !== "systemError") { expect.unreachable( `Expected "system_error", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); async function createTestWorkspace(ctx: Context): Promise<Workspace> { const created = fromBinary( WorkspaceCreateResponseSchema, await createWorkspace( toBinary( WorkspaceCreateRequestSchema, create(WorkspaceCreateRequestSchema, { displayName: "Test Workspace", }), ), ctx, ), ); if (created.result.case !== "ok") { expect.unreachable(`Failed to create workspace: result.case=${created.result.case}`); } if (!created.result.value.workspace) { expect.unreachable("Failed to create workspace: workspace is empty."); } return created.result.value.workspace; } test("Should return an error when workspace_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); // 既存のワークスペースを参照しないことをテスト await createTestWorkspace(ctx); const resp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema, {})), ctx), ); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("workspace_id"); }); test("Should return an error when the workspace does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { workspaceId: { value: workspace.id!.value + ".000", }, }), ), ctx, ), ); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return an empty list", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { workspaceId: workspace.id!, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workspaceId).toEqual(workspace.id!); expect(resp.result.value.workers).toEqual([]); }); async function createTestWorker( workspace: Workspace, { displayName }: Pick<MessageInitShape<typeof CreateRequestSchema>, "displayName">, ctx: Context, ): Promise<Worker> { const created = fromBinary( CreateResponseSchema, await createWorker( toBinary( CreateRequestSchema, create(CreateRequestSchema, { workspaceId: workspace.id!, workerAddKey: workspace.workerAddKey!, displayName: displayName!, }), ), ctx, ), ); if (created.result.case !== "ok") { expect.unreachable(`Failed to create worker: result.case=${created.result.case}`); } if (!created.result.value.worker) { expect.unreachable("Failed to create worker: worker is empty."); } return created.result.value.worker; } test("Should return a worker", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const alice = await createTestWorker(workspace, { displayName: "Alice" }, ctx); const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { workspaceId: workspace.id!, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workspaceId).toEqual(workspace.id!); expect(resp.result.value.workers).toEqual([alice]); }); test("Should return workers in descending order", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const alice = await createTestWorker(workspace, { displayName: "Alice" }, ctx); await new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, 100); }); const bob = await createTestWorker(workspace, { displayName: "Bob" }, ctx); const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { workspaceId: workspace.id!, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workspaceId).toEqual(workspace.id!); expect(resp.result.value.workers).toEqual([bob, alice]); }); test("Should not return workers from other workspaces", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createTestWorkspace(ctx); const alice = await createTestWorker(foo, { displayName: "Alice" }, ctx); const bar = await createTestWorkspace(ctx); await createTestWorker(bar, { displayName: "Bob" }, ctx); const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { workspaceId: foo.id!, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workspaceId).toEqual(foo.id!); expect(resp.result.value.workers).toEqual([alice]); }); test("Should mask fields", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createTestWorkspace(ctx); const alice = await createTestWorker(workspace, { displayName: "Alice" }, ctx); const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { workspaceId: workspace.id!, readMask: { fields: [WorkerSchema.field.id.number], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workers).toHaveLength(1); expect(resp.result.value.workers[0]!.id).toEqual(alice.id!); expect(resp.result.value.workers[0]!.displayName).toBeEmpty(); });
-
-
-
@@ -1,140 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape } from "@bufbuild/protobuf"; import { ListRequestSchema, type ListRequest, } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { type Context, type YamoriDB } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as worker from "../worker"; import * as paidLeaveProvision from "../paid_leave_provision"; export function respond( result: Exclude< NonNullable<MessageInitShape<typeof ListResponseSchema>["result"]>, { case: undefined } >, ): Uint8Array { return toBinary(ListResponseSchema, create(ListResponseSchema, { result })); } export async function list(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: ListRequest; try { req = fromBinary(ListRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } if (!req.workspaceId) { return respond({ case: "missingField", value: { path: "workspace_id", }, }); } let tx; let workspace: YamoriDB["workspaces"]["value"] | undefined; try { tx = ctx.db.transaction( ["workspaces", "workers", "workRecords", "paidLeaveProvision"], "readonly", ); workspace = await tx.objectStore("workspaces").get(req.workspaceId.value); if (!workspace) { return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } } catch (error) { tx?.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during workspace lookup" : `Exception thrown during workspace lookup: ${error}`, }, }); } try { const entries = await worker.getAllForWorkspace( workspace.id, tx.objectStore("workers"), ); const workers = await Promise.all( entries.map(async (entry) => worker.build(entry, { workspace, getWorkRecords(since, until) { return workRecord.getAllForWorker( { workerID: entry.id, workspaceID: workspace.id, filter: since && until ? { since, until } : req.workRecordFilter, }, tx.objectStore("workRecords"), ); }, getPaidLeaveProvisions() { return paidLeaveProvision.getAllForWorker( { workspaceID: workspace.id, workerID: entry.id, filter: req.paidLeaveProvisionFilter, }, tx.objectStore("paidLeaveProvision"), ); }, mask: req.readMask, }), ), ); return respond({ case: "ok", value: { workspaceId: { value: workspace.id }, workers, }, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during listing workers" : `Exception thrown during listing workers: ${error}`, }, }); } }
-
-
-
@@ -1,366 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import * as proto from "@bufbuild/protobuf"; import { ProvidePaidLeaveRequestSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_request_pb.js"; import { ProvidePaidLeaveResponseSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_response_pb.js"; import { PaidLeaveProvisionInputSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_pb.js"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { createWorkspace } from "../../../workspace/v1/workspace_service/_test_utils"; import { createWorker, getWorker } from "./_test_utils"; import { workerService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function providePaidLeave( ctx: Context, request: proto.MessageInitShape<typeof ProvidePaidLeaveRequestSchema>, ): Promise<proto.MessageShape<typeof ProvidePaidLeaveResponseSchema>> { return proto.fromBinary( ProvidePaidLeaveResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "ProvidePaidLeave", data: proto.toBinary( ProvidePaidLeaveRequestSchema, proto.create(ProvidePaidLeaveRequestSchema, request), ), }, ctx, ), ); } for (const field of [ "workspaceId", "workerId", "providePaidLeaveKey", "paidLeave", ] satisfies (keyof (typeof ProvidePaidLeaveRequestSchema)["field"])[]) { const fieldName = ProvidePaidLeaveRequestSchema.field[field].name; test(`Should return missing_field if ${fieldName} is empty`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await providePaidLeave(ctx, { workspaceId: field === "workspaceId" ? undefined : workspace.id, workerId: field === "workerId" ? undefined : worker.id, providePaidLeaveKey: field === "providePaidLeaveKey" ? undefined : worker.providePaidLeaveKey, paidLeave: field === "paidLeave" ? undefined : { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 1, }, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe(fieldName); }); } test("Should return missing_field if provided_at is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { amountDays: 1, }, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("paid_leave.provided_at"); }); test("Should return capability_error if key does not match", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const alice = await createWorker(ctx, workspace); const bob = await createWorker(ctx, workspace); const resp = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: alice.id, providePaidLeaveKey: bob.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 1, }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe( ProvidePaidLeaveRequestSchema.field.providePaidLeaveKey.name, ); }); for (const typeName of [ "yamori.workspace.v1.Workspace", "yamori.worker.v1.Worker", ] as const) { test(`Should return not_found for ${typeName}`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace(ctx); const alice = await createWorker(ctx, foo); const bar = await createWorkspace(ctx); const bob = await createWorker(ctx, bar); const resp = await providePaidLeave(ctx, { workspaceId: typeName === "yamori.workspace.v1.Workspace" ? { value: foo.id?.value + "-000", } : foo.id, workerId: typeName === "yamori.worker.v1.Worker" ? bob.id : alice.id, providePaidLeaveKey: alice.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 1, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe(typeName); }); } test("Should provide paid leave with default expiration date", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 10, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } const provision = resp.result.value.paidLeaveProvision; if (!provision) { expect.unreachable("Got empty paid_leave_provision from ProvidePaidLeave"); } expect(provision.providedAt).toMatchObject({ year: 2020, month: 1, day: 1, }); expect(provision.expiresAt).toMatchObject({ year: 2022, month: 1, day: 1, }); expect(provision.amountDays).toBe(10); expect(provision.remainingDays).toBe(10); expect(provision.isHalvedDayRemaining).toBe(false); const workerAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, }); expect(workerAfter.paidLeaveProvisions).toContainEqual(provision); }); test("Should delete record when amount is 0", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp1 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 10, }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } if (!resp1.result.value.paidLeaveProvision) { expect.unreachable("Got empty paid_leave_provision from ProvidePaidLeave"); } const resp2 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 0, }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } expect(resp2.result.value.paidLeaveProvision).toBeEmpty(); const workerAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, }); expect(workerAfter.paidLeaveProvisions).toHaveLength(0); }); test("Should overwrite", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp1 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, amountDays: 10, }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } const resp2 = await providePaidLeave(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2020, month: 1, day: 1, }, expiresAt: { year: 2030, month: 5, day: 5, }, note: "Foo", }, writeMask: { fields: [ PaidLeaveProvisionInputSchema.field.note.number, PaidLeaveProvisionInputSchema.field.expiresAt.number, ], }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } const workerAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, }); expect(workerAfter.paidLeaveProvisions).toEqual([ expect.objectContaining({ providedAt: expect.objectContaining({ year: 2020, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2030, month: 5, day: 5, }), amountDays: 10, note: "Foo", }), ]); });
-
-
-
@@ -1,238 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { ProvidePaidLeaveRequestSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_request_pb.js"; import { ProvidePaidLeaveResponseSchema } from "@yamori/proto/yamori/worker/v1/provide_paid_leave_response_pb.js"; import { PaidLeaveProvisionInputSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_input_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude< NonNullable<proto.MessageInitShape<typeof ProvidePaidLeaveResponseSchema>["result"]>, { case: undefined } >, ): Uint8Array { return proto.toBinary( ProvidePaidLeaveResponseSchema, proto.create(ProvidePaidLeaveResponseSchema, { result }), ); } export async function providePaidLeave( data: Uint8Array, ctx: Context, ): Promise<Uint8Array> { let req; try { req = proto.fromBinary(ProvidePaidLeaveRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } const { workspaceId, workerId, providePaidLeaveKey, paidLeave } = req; if (!workspaceId) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.workspaceId.name, }, }); } if (!workerId) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.workerId.name, }, }); } if (!providePaidLeaveKey) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.providePaidLeaveKey.name, }, }); } if (!paidLeave) { return respond({ case: "missingField", value: { path: ProvidePaidLeaveRequestSchema.field.paidLeave.name, }, }); } if (!paidLeave.providedAt) { return respond({ case: "missingField", value: { path: [ ProvidePaidLeaveRequestSchema.field.paidLeave.name, PaidLeaveProvisionInputSchema.field.providedAt.name, ].join("."), }, }); } let tx; try { tx = ctx.db.transaction( ["workspaces", "workers", "workRecords", "paidLeaveProvision"], "readwrite", ); } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to start a transaction" : `Exception thrown during transaction start call: ${error}`, }, }); } try { const found = await tx.objectStore("workspaces").get(workspaceId.value); if (!found) { return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during workspace lookup" : `Exception thrown during workspace lookup: ${error}`, }, }); } try { const found = await tx.objectStore("workers").get(workerId.value); if (!found || found.workspaceId !== workspaceId.value) { return respond({ case: "notFound", value: { typeName: "yamori.worker.v1.Worker", }, }); } if (!isSameBytes(found.providePaidLeaveKey, providePaidLeaveKey.key)) { return respond({ case: "capabilityError", value: { path: ProvidePaidLeaveRequestSchema.field.providePaidLeaveKey.name, }, }); } } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during worker lookup" : `Exception thrown during worker lookup: ${error}`, }, }); } try { const store = tx.objectStore("paidLeaveProvision"); const existing = await paidLeaveProvision.getOneByprovidedAt( { workerID: workerId.value, workspaceID: workspaceId.value, providedAt: paidLeave.providedAt, }, store, ); const payload = paidLeaveProvision.write( existing ?? paidLeaveProvision.init(workspaceId.value, workerId.value, paidLeave.providedAt), paidLeave, req.writeMask, ); if (payload.amountDays === 0) { if (existing) { await store.delete(existing.id); } await tx.done; return respond({ case: "ok", value: {}, }); } const addedKey = await store.put(payload); const added = await store.get(addedKey); if (!added) { throw new Error("Failed to retrieve added entry"); } const value = await paidLeaveProvision.build(added, { mask: req.readMask, }); await tx.done; return respond({ case: "ok", value: { paidLeaveProvision: value, }, }); } catch (error) { tx.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during writing paid leave provision record" : `Exception thrown during providing paid leave: ${error}`, }, }); } }
-
-
-
@@ -1,30 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Context, type RPCMessage } from "../../../../types"; import { create } from "./create"; import { get } from "./get"; import { list } from "./list"; import { providePaidLeave } from "./provide_paid_leave"; import { writeWorkRecord } from "./write_work_record"; export async function workerService( request: RPCMessage, ctx: Context, ): Promise<Uint8Array> { switch (request.method) { case "Create": return create(request.data, ctx); case "List": return list(request.data, ctx); case "Get": return get(request.data, ctx); case "WriteWorkRecord": return writeWorkRecord(request.data, ctx); case "ProvidePaidLeave": return providePaidLeave(request.data, ctx); default: throw new Error(`Unknown method "${request.method}" for ${request.service}`); } }
-
-
-
@@ -1,1806 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageInitShape, type MessageShape, } from "@bufbuild/protobuf"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; import { WriteWorkRecordRequestSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_request_pb.js"; import { WriteWorkRecordResponseSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_response_pb.js"; import { RecordKindWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_write_input_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { createWorkspace, createLeaveDefinition, } from "../../../workspace/v1/workspace_service/_test_utils"; import { createWorker, getWorker, providePaidProvision } from "./_test_utils"; import { workerService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function writeWorkRecord( ctx: Context, request: MessageInitShape<typeof WriteWorkRecordRequestSchema>, ): Promise<MessageShape<typeof WriteWorkRecordResponseSchema>> { return fromBinary( WriteWorkRecordResponseSchema, await workerService( { service: "yamori.worker.v1.WorkerService", method: "WriteWorkRecord", data: toBinary( WriteWorkRecordRequestSchema, create(WriteWorkRecordRequestSchema, request), ), }, ctx, ), ); } for (const field of [ "workspaceId", "workerId", "writeWorkRecordKey", "workRecord", ] as const) { const fieldName = WriteWorkRecordRequestSchema.field[field].name; test(`Should return missing_field if ${fieldName} is empty`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: field === "workspaceId" ? undefined : workspace.id, workerId: field === "workerId" ? undefined : worker.id, writeWorkRecordKey: field === "writeWorkRecordKey" ? undefined : worker.writeWorkRecordKey, workRecord: field === "workRecord" ? undefined : { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe(fieldName); }); } test("Should return not_found if workspace does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: { value: workspace.id?.value + "-000", }, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return not_found if worker does not exist", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace(ctx); const bar = await createWorkspace(ctx); const worker = await createWorker(ctx, foo); const resp = await writeWorkRecord(ctx, { workspaceId: bar.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.Worker"); }); test("Should reject malformed capability key", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: { key: new Uint8Array([0, 0, 0]) }, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe( WriteWorkRecordRequestSchema.field.writeWorkRecordKey.name, ); }); for (const target of ["whole day", "am", "pm"] as const) { const kind = ( payload: MessageInitShape<typeof RecordKindWriteInputSchema>["kind"], ): MessageInitShape<typeof WorkRecordBatchWriteInputSchema>["kind"] => { return target === "whole day" ? { case: "dayWhole", value: { kind: payload } } : { case: "dayHalved", value: { am: { kind: target === "am" ? payload : { case: "dayOff", value: {} } }, pm: { kind: target === "pm" ? payload : { case: "dayOff", value: {} } }, }, }; }; const expectKind = ( actual: unknown, expected: MessageInitShape<typeof RecordKindSchema>["kind"], ) => { switch (target) { case "whole day": expect(actual).toMatchObject({ case: "dayWhole", value: { kind: expected, }, }); return; case "am": expect(actual).toMatchObject({ case: "dayHalved", value: { am: { kind: expected }, pm: { kind: { case: "dayOff" } }, }, }); return; case "pm": expect(actual).toMatchObject({ case: "dayHalved", value: { am: { kind: { case: "dayOff" } }, pm: { kind: expected }, }, }); return; } }; test(`Should reject nonexistent leave_id set as ${target}`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], kind: kind({ case: "workspaceDefinedLeaveId", value: { value: leave.id?.value + "-000", }, }), }, }); if (resp.result.case !== "notFound") { console.dir(resp.result.value); expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test(`Should reject reference to leave not started yet set to ${target}`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 2 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2024, month: 1, day: 2 }, ], kind: kind({ case: "workspaceDefinedLeaveId", value: leave.id!, }), }, }); if (resp.result.case !== "notFound") { console.dir(resp.result.value); expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test(`Should prevent setting a paid leave to ${target} if none is available`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2022, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], kind: kind({ case: "paidLeave", value: {}, }), }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.PaidLeaveProvision"); }); test(`Should prevent setting a paid leave to ${target} if none has corresponding provided_at`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2023, month: 1, day: 2, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], kind: kind({ case: "paidLeave", value: { providedAt: { year: 2023, month: 1, day: 1, }, }, }), }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.PaidLeaveProvision"); }); test(`Should prevent setting paid leaves to ${target} result in exceeding amount`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }, { year: 2025, month: 1, day: 2 }, ], kind: kind({ case: "paidLeave", value: {}, }), }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.PaidLeaveProvision"); }); test(`Should allow paid leaves on ${target}`, async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 10, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }, ], kind: kind({ case: "paidLeave", value: {}, }), }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2026, month: 1, day: 1, }, }, readMask: { fields: [ WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], }, }); expect(after).toEqual( expect.objectContaining({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), expect.objectContaining({ date: expect.objectContaining({ year: 2025, month: 1, day: 1, }), }), ], paidLeaveProvisions: [ expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2026, month: 1, day: 1, }), amountDays: 10, remainingDays: target === "whole day" ? 8 : 9, }), ], }), ); expectKind(after.workRecords[0]?.kind, { case: "paidLeave", value: { providedAt: { year: 2024, month: 1, day: 1, }, }, }); expectKind(after.workRecords[1]?.kind, { case: "paidLeave", value: { providedAt: { year: 2024, month: 1, day: 1, }, }, }); }); } test("Should reject nonexistent leave_id (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "workspaceDefinedLeaveId", value: { value: leave.id?.value + "-000" }, }, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test("Should reject reference to leave not started yet (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 2 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2024, month: 1, day: 2 }, ], record: { case: "workspaceDefinedLeaveId", value: leave.id!, }, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test("Should prevent setting a paid leave if none is available (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2022, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "paidLeave", value: {}, }, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.PaidLeaveProvision"); }); test("Should prevent setting a paid leave if none has corresponding provided_at (deprecated)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2023, month: 1, day: 2, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "paidLeave", value: { providedAt: { year: 2023, month: 1, day: 1, }, }, }, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.PaidLeaveProvision"); }); test("Should prevent setting paid leaves result in exceeding amount (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }, ], record: { case: "paidLeave", value: {}, }, }, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.worker.v1.PaidLeaveProvision"); }); test("Should allow paid leaves (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 10, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }, ], record: { case: "paidLeave", value: {}, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2026, month: 1, day: 1, }, }, readMask: { fields: [ WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "paidLeave", value: expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), }), }), { date: expect.objectContaining({ year: 2025, month: 1, day: 1, }), record: expect.objectContaining({ case: "paidLeave", value: expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), }), }, ], paidLeaveProvisions: [ expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2026, month: 1, day: 1, }), amountDays: 10, remainingDays: 8, }), ], }); }); test("Should use next paid leaves if current one is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2023, month: 1, day: 1, }, amountDays: 1, }, }); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2024, month: 1, day: 2 }, { year: 2024, month: 1, day: 3 }, ], kind: { case: "dayHalved", value: { am: { kind: { case: "dayOff", value: {}, }, }, pm: { kind: { case: "paidLeave", value: {}, }, }, }, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2026, month: 1, day: 1, }, }, readMask: { fields: [ WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], }, }); expect(after).toMatchObject({ workRecords: [ { date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), kind: { case: "dayHalved", value: { am: expect.anything(), pm: { kind: { case: "paidLeave", value: { providedAt: { year: 2023, month: 1, day: 1, }, }, }, }, }, }, }, { date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), kind: { case: "dayHalved", value: { am: expect.anything(), pm: { kind: { case: "paidLeave", value: { providedAt: { year: 2023, month: 1, day: 1, }, }, }, }, }, }, }, { date: expect.objectContaining({ year: 2024, month: 1, day: 3, }), kind: { case: "dayHalved", value: { am: expect.anything(), pm: { kind: { case: "paidLeave", value: { providedAt: { year: 2024, month: 1, day: 1, }, }, }, }, }, }, }, ], paidLeaveProvisions: [ expect.objectContaining({ providedAt: expect.objectContaining({ year: 2023, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2025, month: 1, day: 1, }), amountDays: 1, remainingDays: 0, }), expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2026, month: 1, day: 1, }), amountDays: 1, remainingDays: 0, isHalvedDayRemaining: true, }), ], }); }); test("Should use next paid leaves if current one is empty (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2023, month: 1, day: 1, }, amountDays: 1, }, }); await providePaidProvision(ctx, { workspaceId: workspace.id, workerId: worker.id, providePaidLeaveKey: worker.providePaidLeaveKey, paidLeave: { providedAt: { year: 2024, month: 1, day: 1, }, amountDays: 1, }, }); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2024, month: 1, day: 2 }, ], record: { case: "paidLeave", value: {}, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2026, month: 1, day: 1, }, }, readMask: { fields: [ WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "paidLeave", value: expect.objectContaining({ providedAt: expect.objectContaining({ year: 2023, month: 1, day: 1, }), }), }), }), { date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), record: expect.objectContaining({ case: "paidLeave", value: expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), }), }), }, ], paidLeaveProvisions: [ expect.objectContaining({ providedAt: expect.objectContaining({ year: 2023, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2025, month: 1, day: 1, }), amountDays: 1, remainingDays: 0, }), expect.objectContaining({ providedAt: expect.objectContaining({ year: 2024, month: 1, day: 1, }), expiresAt: expect.objectContaining({ year: 2026, month: 1, day: 1, }), amountDays: 1, remainingDays: 0, }), ], }); }); test("Should write a note", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workRecords).toEqual([ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), note: "Foo", }), ]); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 1, }, }, readMask: { fields: [WorkerSchema.field.workRecords.number], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), note: "Foo", }), ], }); }); test("Should write a whole-day record", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], kind: { case: "dayWhole", value: { kind: { case: "workspaceDefinedLeaveId", value: leave.id!, }, }, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workRecords).toEqual([ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), kind: expect.objectContaining({ case: "dayWhole", value: expect.objectContaining({ kind: expect.objectContaining({ case: "workspaceDefinedLeave", value: expect.objectContaining({ id: leave.id, }), }), }), }), }), ]); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 1, }, }, readMask: { fields: [WorkerSchema.field.workRecords.number], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), kind: expect.objectContaining({ case: "dayWhole", value: expect.objectContaining({ kind: expect.objectContaining({ case: "workspaceDefinedLeave", value: expect.objectContaining({ id: leave.id, }), }), }), }), }), ], }); }); test("Should write a record (deprecated)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const leave = await createLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2024, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "workspaceDefinedLeaveId", value: leave.id!, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workRecords).toEqual([ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "workspaceDefinedLeave", value: expect.objectContaining({ id: leave.id, }), }), }), ]); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 1, }, }, readMask: { fields: [WorkerSchema.field.workRecords.number], }, }); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "workspaceDefinedLeave", value: expect.objectContaining({ id: leave.id, }), }), }), ], }); }); test("Should write to multiple dates (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [ { year: 2024, month: 1, day: 1 }, { year: 2024, month: 1, day: 2 }, ], record: { case: "workingDay", value: { hasWorkerWorked: true, timeOffs: [], }, }, }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.workRecords).toEqual([ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), ]); const after = await getWorker(ctx, { workspaceId: workspace.id, workerId: worker.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 2, }, }, readMask: { fields: [WorkerSchema.field.workRecords.number], }, }); expect(after.workRecords).toHaveLength(2); expect(after).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), record: expect.objectContaining({ case: "workingDay", value: expect.objectContaining({ hasWorkerWorked: true, timeOffs: [], }), }), }), ], }); }); test("Should write to specified user (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const alice = await createWorker(ctx, workspace); const bob = await createWorker(ctx, workspace); const resp1 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: alice.id, writeWorkRecordKey: alice.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "dayOff", value: {}, }, }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } const resp2 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: bob.id, writeWorkRecordKey: bob.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 2 }], note: "Holiday", }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } const aliceAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: alice.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 3, }, }, }); const bobAfter = await getWorker(ctx, { workspaceId: workspace.id, workerId: bob.id, workRecordFilter: { since: { year: 2024, month: 1, day: 1, }, until: { year: 2024, month: 1, day: 3, }, }, }); expect(aliceAfter).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 1, }), record: expect.objectContaining({ case: "dayOff", value: expect.anything(), }), }), ], }); expect(bobAfter).toMatchObject({ workRecords: [ expect.objectContaining({ date: expect.objectContaining({ year: 2024, month: 1, day: 2, }), note: "Holiday", }), ], }); }); test("Should overwrite respecting mask (deprecated field)", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const worker = await createWorker(ctx, workspace); const resp1 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], record: { case: "dayOff", value: {}, }, note: "Foo", }, writeMask: { fields: [WorkRecordBatchWriteInputSchema.field.dayOff.number], }, }); if (resp1.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp1.result.case)}`); } expect(resp1.result.value.workRecords).toHaveLength(1); expect(resp1.result.value.workRecords[0]).toMatchObject({ record: { case: "dayOff", value: {}, }, }); expect(resp1.result.value.workRecords[0]!.note).toBeEmpty(); const resp2 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], note: "Foo", }, writeMask: { fields: [WorkRecordBatchWriteInputSchema.field.note.number], }, }); if (resp2.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp2.result.case)}`); } expect(resp2.result.value.workRecords).toHaveLength(1); expect(resp2.result.value.workRecords[0]).toMatchObject({ record: { case: "dayOff", value: {}, }, }); expect(resp2.result.value.workRecords[0]!.note).toBe("Foo"); const resp3 = await writeWorkRecord(ctx, { workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates: [{ year: 2024, month: 1, day: 1 }], }, }); if (resp3.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp3.result.case)}`); } expect(resp3.result.value.workRecords).toHaveLength(1); expect(resp3.result.value.workRecords[0]!.record.case).toBeEmpty(); expect(resp3.result.value.workRecords[0]!.note).toBeEmpty(); });
-
-
-
@@ -1,292 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape, type MessageShape, } from "@bufbuild/protobuf"; import { WriteWorkRecordRequestSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_request_pb.js"; import { WriteWorkRecordResponseSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_response_pb.js"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; import { isSameBytes, packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as workRecord from "../../../work_record/v1/work_record"; import * as paidLeaveProvision from "../paid_leave_provision"; function respond( result: Exclude< NonNullable<MessageInitShape<typeof WriteWorkRecordResponseSchema>["result"]>, { case: undefined } >, ): Uint8Array { return toBinary( WriteWorkRecordResponseSchema, create(WriteWorkRecordResponseSchema, { result }), ); } export async function writeWorkRecord( data: Uint8Array, { db }: Context, ): Promise<Uint8Array> { let req: MessageShape<typeof WriteWorkRecordRequestSchema>; try { req = fromBinary(WriteWorkRecordRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } if (!req.workspaceId) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.workspaceId.name, }, }); } if (!req.workerId) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.workerId.name, }, }); } if (!req.writeWorkRecordKey) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.writeWorkRecordKey.name, }, }); } if (!req.workRecord) { return respond({ case: "missingField", value: { path: WriteWorkRecordRequestSchema.field.workRecord.name, }, }); } if (!req.workRecord.dates.length) { return respond({ case: "missingField", value: { path: [ WriteWorkRecordRequestSchema.field.workRecord.name, WorkRecordBatchWriteInputSchema.field.dates.name, ].join("."), }, }); } let workspace: YamoriDB["workspaces"]["value"]; try { const found = await db.get("workspaces", req.workspaceId.value); if (!found) { return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } if (req.workRecord.record.case === "workspaceDefinedLeaveId") { const leaveId = req.workRecord.record.value.value; const match = found.leaveDefinitions.find((def) => def.id === leaveId); if (!match) { return respond({ case: "notFound", value: { typeName: "yamori.work_record.v1.Leave", }, }); } for (const date of req.workRecord.dates) { const packed = packDate(date); if (!match.revisions.some((rev) => rev.startAtPackedDate <= packed)) { // TODO: ちょっと適切じゃない気がするので、エラーの追加含めて検討 return respond({ case: "notFound", value: { typeName: "yamori.work_record.v1.Leave", }, }); } } } workspace = found; } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during workspace lookup" : `Exception thrown during workspace lookup: ${error}`, }, }); } try { const worker = await db.get("workers", req.workerId.value); if (!worker || worker.workspaceId !== req.workspaceId.value) { return respond({ case: "notFound", value: { typeName: "yamori.worker.v1.Worker", }, }); } if (!isSameBytes(worker.writeWorkRecordKey, req.writeWorkRecordKey.key)) { return respond({ case: "capabilityError", value: { path: WriteWorkRecordRequestSchema.field.writeWorkRecordKey.name, }, }); } } catch (error) { return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during worker lookup" : `Exception thrown during worker lookup: ${error}`, }, }); } let tx; try { tx = db.transaction(["workRecords", "paidLeaveProvision"], "readwrite"); // idb は abort() を呼び出すことが考慮されていないため、エラーハンドラが // 一つもない状態で tx.abort() をすると done が reject されてしまう。 // Unhandled Rejection を防ぐためにダミーの catch が必要。 tx.done.catch(() => {}); const store = tx.objectStore("workRecords"); const paidLeaveProvisions = tx.objectStore("paidLeaveProvision"); const added: YamoriDB["workRecords"]["value"][] = []; for (const date of req.workRecord.dates) { const packed = packDate(date); const existing = await store .index("workspaceId/workerId/datePacked") .get([req.workspaceId.value, req.workerId.value, packed]); const payload = await workRecord.write( existing || { recordId: crypto.randomUUID(), workerId: req.workerId.value, workspaceId: req.workspaceId.value, datePacked: packed, }, req.workRecord, { workspace, getPaidLeaveProvisions() { return paidLeaveProvision.getAllForWorker( { workerID: req.workerId!.value, workspaceID: req.workspaceId!.value, }, paidLeaveProvisions, ); }, mask: req.writeMask, }, ); await store.put(payload); added.push(payload); await paidLeaveProvision.syncRemainings({ workerID: req.workerId!.value, workspaceID: req.workspaceId!.value, paidLeaveProvisions, workRecords: store, }); } const value = added.map((entry) => workRecord.build(entry, { workspace, mask: req.readMask, }), ); await tx.done; return respond({ case: "ok", value: { workRecords: value, }, }); } catch (error) { tx?.abort(); if (error instanceof workRecord.NonexistentLeaveIDError) { return respond({ case: "notFound", value: { typeName: "yamori.work_record.v1.Leave", }, }); } if ( error instanceof workRecord.NoPaidLeaveAvailableError || error instanceof workRecord.NoPaidLeaveProvidedAtError || // TODO: 適切ではないのでエラーを追加する error instanceof workRecord.InsufficientPaidLeaveRemainingsError ) { return respond({ case: "notFound", value: { typeName: "yamori.worker.v1.PaidLeaveProvision", }, }); } return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "An error occurred during writing work record" : `Exception thrown during work record writes: ${error}`, }, }); } }
-
-
-
@@ -1,97 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { WorkspaceReadMaskSchema } from "@yamori/proto/yamori/workspace/v1/workspace_read_mask_pb.js"; import { type IDBPObjectStore, type StoreNames } from "idb"; import { type YamoriDB } from "../../../types"; import * as leave from "../../work_record/v1/leave"; import * as paidLeaveProvisionTable from "../../paid_leave_provision/v1/paid_leave_provision_table"; export async function getOneById( id: string, store: IDBPObjectStore< YamoriDB, ArrayLike<StoreNames<YamoriDB>>, "workspaces", "readonly" | "readwrite" >, ): Promise<YamoriDB["workspaces"]["value"] | undefined> { return store.get(id); } export interface BuildOptions { mask?: proto.MessageInitShape<typeof WorkspaceReadMaskSchema>; getPaidLeaveProvisionTables( workspaceId?: string, ): Promise<YamoriDB["paidLeaveProvisionTable"]["value"][]>; } export async function build( entry: YamoriDB["workspaces"]["value"], { mask: { fields = WorkspaceSchema.fields.map((field) => field.number), leaveDefinitionsMask, paidLeaveProvisionTableMask, } = {}, getPaidLeaveProvisionTables, }: BuildOptions, ): Promise<proto.MessageShape<typeof WorkspaceSchema>> { const init: proto.MessageInitShape<typeof WorkspaceSchema> = {}; for (const field of fields) { switch (field) { case WorkspaceSchema.field.id.number: init.id = { value: entry.id }; break; case WorkspaceSchema.field.displayName.number: init.displayName = entry.displayName; break; case WorkspaceSchema.field.updateKey.number: init.updateKey = { key: entry.capabilities.updateKey }; break; case WorkspaceSchema.field.deletionKey.number: init.deletionKey = { key: entry.capabilities.deletionKey }; break; case WorkspaceSchema.field.workerAddKey.number: init.workerAddKey = { key: entry.capabilities.workerAddKey }; break; case WorkspaceSchema.field.createLeaveDefinitionKey.number: init.createLeaveDefinitionKey = { key: entry.capabilities.createLeaveDefinitionKey, }; break; case WorkspaceSchema.field.leaveDefinitions.number: init.leaveDefinitions = entry.leaveDefinitions.map((def) => leave.build(def, { mask: leaveDefinitionsMask, }), ); break; case WorkspaceSchema.field.abbreviations.number: init.abbreviations = { dayoff: entry.abbreviations.dayoff, worked: entry.abbreviations.worked, skipWork: entry.abbreviations.skippedWork, paidLeave: entry.abbreviations.paidLeave, }; break; case WorkspaceSchema.field.paidLeaveProvisionTables.number: init.paidLeaveProvisionTables = await Promise.all( (await getPaidLeaveProvisionTables(entry.id)).map(async (table) => { return paidLeaveProvisionTable.build(table, { mask: paidLeaveProvisionTableMask, getPaidLeaveProvisionTables, }); }), ); break; } } return proto.create(WorkspaceSchema, init); }
-
-
-
@@ -1,117 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { expect } from "bun:test"; import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type Context } from "../../../../types"; import { workspaceService } from "./service"; export async function createWorkspace( ctx: Context, ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( CreateResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Create", data: toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for workspace creation, found ${JSON.stringify(resp.result.case)}`, ); } if (!resp.result.value.workspace) { expect.unreachable( `Expected Create method to return a created workspace, got empty value`, ); } return resp.result.value.workspace; } export async function getWorkspace( ctx: Context, id: MessageInitShape<typeof WorkspaceSchema>["id"], ): Promise<MessageShape<typeof WorkspaceSchema>> { const resp = fromBinary( GetResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "Get", data: toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: id, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for getting workspace op, found ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; } export async function createLeaveDefinition( ctx: Context, request: MessageInitShape<typeof CreateLeaveDefinitionRequestSchema>, ): Promise<MessageShape<typeof LeaveSchema>> { const resp = fromBinary( CreateLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", data: toBinary( CreateLeaveDefinitionRequestSchema, create(CreateLeaveDefinitionRequestSchema, request), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for creating leave definition, got ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; }
-
-
-
@@ -1,281 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { KeySchema } from "@yamori/proto/yamori/idempotency/v1/key_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { create as method } from "./create"; import { list } from "./list"; beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return a missing field error", async () => { const resp = fromBinary( CreateResponseSchema, await method(toBinary(CreateRequestSchema, create(CreateRequestSchema, {})), { db: await openDB("test"), }), ); if (resp.result.case !== "missingField") { expect.unreachable(); } expect(resp.result.value.path).toBe("display_name"); }); test("Should return an error for invalid message", async () => { const resp = fromBinary( CreateResponseSchema, await method( toBinary( KeySchema, create(KeySchema, { value: "foo", }), ), { db: await openDB("test"), }, ), ); if (resp.result.case !== "systemError") { expect.unreachable(); } expect(resp.result.value.code).toBe("INVALID_MESSAGE"); }); test("Should append a workspace", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.displayName).toBe("Foo"); expect(resp.result.value.workspace?.id?.value).toStartWith("ws-"); expect(resp.result.value.workspace?.updateKey?.key.length).toBeGreaterThan(0); const listResp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), ctx), ); if (listResp.result.case !== "ok") { expect.unreachable(); } expect(listResp.result.value.workspaces).toHaveLength(1); expect(listResp.result.value.workspaces[0]?.displayName).toBe("Foo"); expect(listResp.result.value.workspaces[0]?.id?.value).toStartWith("ws-"); expect(listResp.result.value.workspaces[0]?.updateKey?.key.length).toBeGreaterThan(0); }); test("Should allow inserting duplicating displayName", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const [resp1, resp2] = await Promise.all([ method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), ctx, ).then((r) => fromBinary(CreateResponseSchema, r)), method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", }), ), ctx, ).then((r) => fromBinary(CreateResponseSchema, r)), ]); if (resp1.result.case !== "ok") { expect.unreachable(); } if (resp2.result.case !== "ok") { expect.unreachable(); } expect(resp1.result.value.workspace?.id?.value).toStartWith("ws-"); expect(resp2.result.value.workspace?.id?.value).toStartWith("ws-"); expect(resp1.result.value.workspace?.id?.value).not.toEqual( resp2.result.value.workspace?.id?.value, ); const listResp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), ctx), ); if (listResp.result.case !== "ok") { expect.unreachable(); } expect(listResp.result.value.workspaces).toHaveLength(2); expect(listResp.result.value.workspaces[0]!.displayName).toBe("Foo"); expect(listResp.result.value.workspaces[1]!.displayName).toBe("Foo"); }); test("Should mask output", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [WorkspaceSchema.field.displayName.number] }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.displayName).toBe("Foo"); expect(resp.result.value.workspace?.id).toBeEmpty(); expect(resp.result.value.workspace?.updateKey).toBeEmpty(); }); test("Should generate system leave definitions", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [WorkspaceSchema.field.leaveDefinitions.number], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.leaveDefinitions).toContainEqual( expect.objectContaining({ id: expect.anything(), displayName: "育児休業", isWorkerDeemedToBeWorked: true, revisions: [ expect.objectContaining({ revisionId: expect.anything(), startAt: expect.anything(), snapshot: expect.objectContaining({ isWorkerDeemedToBeWorked: true, }), }), ], }), ); expect(resp.result.value.workspace?.leaveDefinitions).toContainEqual( expect.objectContaining({ id: expect.anything(), displayName: "介護休業", isWorkerDeemedToBeWorked: true, revisions: [ expect.objectContaining({ revisionId: expect.anything(), startAt: expect.anything(), snapshot: expect.objectContaining({ isWorkerDeemedToBeWorked: true, }), }), ], }), ); expect(resp.result.value.workspace?.leaveDefinitions).toContainEqual( expect.objectContaining({ id: expect.anything(), displayName: "産前産後休業", isWorkerDeemedToBeWorked: true, revisions: [ expect.objectContaining({ revisionId: expect.anything(), startAt: expect.anything(), snapshot: expect.objectContaining({ isWorkerDeemedToBeWorked: true, }), }), ], }), ); }); test("Should copy system provision tables into the created workspace", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( CreateResponseSchema, await method( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName: "Foo", readMask: { fields: [WorkspaceSchema.field.paidLeaveProvisionTables.number], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspace?.paidLeaveProvisionTables).toHaveLength(5); });
-
-
-
@@ -1,249 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create as createMessage, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema, type CreateRequest, } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { createRandomBytes, packDate } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; export async function create(data: Uint8Array, { db }: Context): Promise<Uint8Array> { let req: CreateRequest; try { req = fromBinary(CreateRequestSchema, data); } catch (error) { return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }, }), ); } if (!req.displayName) { return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "missingField", value: { path: "display_name", }, }, }), ); } // TODO: Handle idempotency_key (how?) const id = "ws-" + crypto.randomUUID(); const deletionKey = new Uint8Array(16); self.crypto.getRandomValues(deletionKey); const updateKey = new Uint8Array(16); self.crypto.getRandomValues(updateKey); const workerAddKey = new Uint8Array(16); self.crypto.getRandomValues(workerAddKey); const createLeaveDefinitionKey = new Uint8Array(16); self.crypto.getRandomValues(createLeaveDefinitionKey); const tx = db.transaction(["workspaces", "paidLeaveProvisionTable"], "readwrite"); let addedKey: IDBValidKey; try { addedKey = await tx.objectStore("workspaces").add({ id, displayName: req.displayName, abbreviations: { dayoff: "休日", worked: "出勤", skippedWork: "欠勤", paidLeave: "年休", }, capabilities: { deletionKey, updateKey, workerAddKey, createLeaveDefinitionKey, }, // 労働基準法 第三十九条 第十項 で定義されている「出勤したものとみなす」休業に // ついてはどんな会社でも必ず必須となるため一律で登録する。 leaveDefinitions: [ { id: `lv-${self.crypto.randomUUID()}`, displayName: "育児休業", abbrName: "育休", updateKey: createRandomBytes(16), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第七十九号 startAtPackedDate: packDate({ year: 1994, month: 4, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, { id: `lv-${self.crypto.randomUUID()}`, displayName: "介護休業", abbrName: "介護", updateKey: createRandomBytes(16), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 法律第百七号 startAtPackedDate: packDate({ year: 1995, month: 10, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, { id: `lv-${self.crypto.randomUUID()}`, displayName: "産前産後休業", abbrName: "産休", updateKey: createRandomBytes(16), createdBy: "system", revisions: [ { id: `lr-${self.crypto.randomUUID()}`, // 最初からある (法律第四十九号) // 三十九条の施行年月日は調べてもわからなかったので早い方 (遅い方は11/1) // ただぶっちゃけ昔のことだからシステム運用的にはどっちでもいい。 // こんな昔のデータを入れるとしたら付与日数なんかも過去の計算式を用意 // する必要があるため、あくまでも記録・教育的な側面のデータ。 startAtPackedDate: packDate({ year: 1947, month: 9, day: 1, }), snapshot: { isWorkerDeemedToBeWorked: true, }, }, ], }, ], updatedAt: Date.now(), }); } catch (error) { tx.abort(); return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to write workspace" : `Failed to write workspace entry to IndexedDB: ${error}`, }, }, }), ); } try { const added = await workspace.getOneById(addedKey, tx.objectStore("workspaces")); if (!added) { throw "Failed to query an added entry"; } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); for await (const systemTable of paidLeaveProvisionTables .index("workspaceId") .iterate("")) { if (systemTable.value.workspaceId) { continue; } await paidLeaveProvisionTables.put({ ...systemTable.value, id: `pt-${crypto.randomUUID()}`, workspaceId: id, baseId: systemTable.value.id, }); } const ws = await workspace.build(added, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.done; return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "ok", value: { workspace: ws, }, }, }), ); } catch (error) { tx.abort(); return toBinary( CreateResponseSchema, createMessage(CreateResponseSchema, { result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to read an added workspace" : `Failed to read an added workspace entry from IndexedDB: ${error}`, }, }, }), ); } }
-
-
packages/idb_backend/src/yamori/workspace/v1/workspace_service/create_leave_definition.test.ts (deleted)
-
@@ -1,230 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { idbBackend } from "../../../../lib"; import { type Context } from "../../../../types"; import { CONTEXT } from "../../../../symbols"; import { createWorkspace, getWorkspace } from "./_test_utils"; import { workspaceService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); export async function createLeaveDefinition( ctx: Context, request: MessageInitShape<typeof CreateLeaveDefinitionRequestSchema>, ): Promise<MessageShape<typeof CreateLeaveDefinitionResponseSchema>> { return fromBinary( CreateLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", data: toBinary( CreateLeaveDefinitionRequestSchema, create(CreateLeaveDefinitionRequestSchema, request), ), }, ctx, ), ); } test("Should return missing_field if workspace_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { leaveDefinition: { displayName: "Bar", revisions: [ { startAt: { year: 2000, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, ], }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("workspace_id"); }); test("Should return missing_field if create_leave_definition_key is absent", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { displayName: "Bar", }, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("create_leave_definition_key"); }); test("Should return missing_field if leave_definition.display_name is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { displayName: "", revisions: [ { startAt: { year: 2000, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, ], }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("leave_definition.display_name"); }); test("Should reject manipulated capability key", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { displayName: "Bar", revisions: [ { startAt: { year: 2000, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, ], }, createLeaveDefinitionKey: { key: new Uint8Array([0, 0, 0]) }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", found ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("create_leave_definition_key"); }); test("Should return not_found", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: { value: `${created.id?.value}-FOO`, }, leaveDefinition: { displayName: "Bar", revisions: [ { startAt: { year: 2000, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, ], }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should create a leave definition", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace(ctx); const resp = await createLeaveDefinition(ctx, { workspaceId: created.id, leaveDefinition: { displayName: "Bar", revisions: [ { startAt: { year: 2000, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, createLeaveDefinitionKey: created.createLeaveDefinitionKey, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", found ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.displayName).toBe("Bar"); const createdDefinition = resp.result.value; if (!createdDefinition.id) { expect.unreachable(`Created leave definition has empty ID`); } const updated = await getWorkspace(ctx, created.id); const def = updated.leaveDefinitions.find( (def) => def.id && def.id.value === createdDefinition.id!.value, ); if (!def) { expect.unreachable(`Created definition does not exist in workspace`); } expect(def.updateKey).toHaveProperty("key"); expect(def.displayName).toBe("Bar"); expect(def.currentRevision?.snapshot?.isWorkerDeemedToBeWorked).toBe(true); expect(def.currentRevision?.startAt).toMatchObject({ year: 2000, month: 1, day: 1, }); expect(def.revisions).toHaveLength(1); });
-
-
-
@@ -1,200 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape } from "@bufbuild/protobuf"; import { CreateLeaveDefinitionRequestSchema, type CreateLeaveDefinitionRequest, } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { createRandomBytes, isSameBytes, packDate } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as leave from "../../../work_record/v1/leave"; import * as workspace from "../workspace"; function respond( payload: Extract< MessageInitShape<typeof CreateLeaveDefinitionResponseSchema>["result"], { case: string } >, ): Uint8Array { return toBinary( CreateLeaveDefinitionResponseSchema, create(CreateLeaveDefinitionResponseSchema, { result: payload, }), ); } export async function createLeaveDefinition( data: Uint8Array, { db }: Context, ): Promise<Uint8Array> { let req: CreateLeaveDefinitionRequest; try { req = fromBinary(CreateLeaveDefinitionRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } if (!req.workspaceId?.value) { return respond({ case: "missingField", value: { path: req.workspaceId ? "workspace_id.value" : "workspace_id", }, }); } if (!req.leaveDefinition) { return respond({ case: "missingField", value: { path: "leave_definition", }, }); } if (!req.leaveDefinition.displayName) { return respond({ case: "missingField", value: { path: "leave_definition.display_name", }, }); } if (!req.createLeaveDefinitionKey?.key) { return respond({ case: "missingField", value: { path: req.createLeaveDefinitionKey ? "create_leave_definition_key.key" : "create_leave_definition_key", }, }); } if (req.leaveDefinition.revisions.length === 0) { return respond({ case: "missingField", value: { path: "leave_definition.revisions" }, }); } if (req.leaveDefinition.revisions.some((revision) => !revision.startAt)) { return respond({ case: "missingField", value: { path: "leave_definition.revisions.start_at" }, }); } if (req.leaveDefinition.revisions.some((revision) => !revision.snapshot)) { return respond({ case: "missingField", value: { path: "leave_definition.revisions.snapshot" }, }); } let tx; try { tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); const found = await workspace.getOneById(req.workspaceId.value, store); if (!found) { await tx.done; return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } if ( !isSameBytes( req.createLeaveDefinitionKey.key, found.capabilities.createLeaveDefinitionKey, ) ) { return respond({ case: "capabilityError", value: { path: "create_leave_definition_key", }, }); } const updateKey = new Uint8Array(16); self.crypto.getRandomValues(updateKey); const entry: YamoriDB["workspaces"]["value"]["leaveDefinitions"][number] = { id: `lv-${self.crypto.randomUUID()}`, displayName: req.leaveDefinition.displayName, abbrName: req.leaveDefinition.abbreviationName, updateKey, deletionKey: createRandomBytes(16), createdBy: "user", revisions: req.leaveDefinition.revisions.map((revision) => { return { id: `lr-${self.crypto.randomUUID()}`, startAtPackedDate: packDate(revision.startAt!), snapshot: { isWorkerDeemedToBeWorked: revision.snapshot!.isWorkerDeemedToBeWorked, }, }; }), }; await store.put({ ...found, leaveDefinitions: [...found.leaveDefinitions, entry], }); const updatedWorkspace = await workspace.getOneById(found.id, store); const updated = updatedWorkspace?.leaveDefinitions.find((def) => def.id === entry.id); if (!updated) { await tx.done; return respond({ case: "systemError", value: { code: "IDB_ERROR", message: "Unable to find updated data", }, }); } const value = leave.build(updated, { mask: req.readMask, }); await tx.done; return respond({ case: "ok", value }); } catch (error) { tx?.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to delete a workspace" : `Exception thrown during delete transaction: ${error}`, }, }); } }
-
-
-
@@ -1,295 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageInitShape, type DescMessage, type MessageShape, } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { DeleteRequestSchema } from "@yamori/proto/yamori/workspace/v1/delete_request_pb.js"; import { DeleteResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { create as createHandler } from "./create"; import { deleteWorkspace as deleteHandler } from "./delete"; import { list } from "./list"; function bind<RequestSchema extends DescMessage, ResponseSchema extends DescMessage>( requestSchema: RequestSchema, responseSchema: ResponseSchema, handler: (req: Uint8Array, ctx: Context) => Promise<Uint8Array>, ): ( req: MessageInitShape<RequestSchema>, ctx: Context, ) => Promise<MessageShape<ResponseSchema>> { return async (request, ctx) => fromBinary( responseSchema, await handler(toBinary(requestSchema, create(requestSchema, request)), ctx), ); } const createWorkspace = bind(CreateRequestSchema, CreateResponseSchema, createHandler); const listWorkspaces = bind(ListRequestSchema, ListResponseSchema, list); const deleteWorkspace = bind(DeleteRequestSchema, DeleteResponseSchema, deleteHandler); beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return an error on IndexedDB exceptions", async () => { const resp = await deleteWorkspace( { id: { value: "foo-bar", }, deletionKey: { key: new Uint8Array([]), }, }, { db: await openDB("test"), }, ); if (resp.result.case !== "systemError") { expect.unreachable(`Expected "systemError", found "${resp.result.case}"`); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); test("Should return a missing field error if ID is nonexistent", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!created.result.value.workspace.deletionKey?.key) { expect.unreachable("Create handler did not set deleteion_key"); } const deleted = await deleteWorkspace( { id: { value: created.result.value.workspace.id.value + "-1111", }, deletionKey: created.result.value.workspace.deletionKey, }, ctx, ); if (deleted.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found "${deleted.result.case}"`); } const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); }); test("Should return an error if ID field is absent", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!created.result.value.workspace.deletionKey?.key) { expect.unreachable("Create handler did not set deleteion_key"); } const deleted = await deleteWorkspace( { id: {}, deletionKey: created.result.value.workspace.deletionKey, }, ctx, ); if (deleted.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found "${deleted.result.case}"`); } expect(deleted.result.value.path).toBe("id.value"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Foo"); }); test("Should return an error for insufficient capability", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace( { displayName: "Foo", }, ctx, ); if (foo.result.case !== "ok") { expect.unreachable(); } if (!foo.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!foo.result.value.workspace.deletionKey?.key) { expect.unreachable("Create handler did not set deleteion_key"); } const bar = await createWorkspace( { displayName: "Bar", }, ctx, ); if (bar.result.case !== "ok") { expect.unreachable(); } if (!bar.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!bar.result.value.workspace.deletionKey?.key) { expect.unreachable("Create handler did not set deleteion_key"); } // キーなし const missingKey = await deleteWorkspace( { id: foo.result.value.workspace.id, }, ctx, ); if (missingKey.result.case !== "capabilityError") { expect.unreachable(`Expected "capabilityError", found "${missingKey.result.case}"`); } expect(missingKey.result.value.path).toBe("deletionKey"); // 別のワークスペースのキー const invalidKey = await deleteWorkspace( { id: foo.result.value.workspace.id, deletionKey: bar.result.value.workspace.deletionKey, }, ctx, ); if (invalidKey.result.case !== "capabilityError") { expect.unreachable(`Expected "capabilityError", found "${invalidKey.result.case}"`); } expect(invalidKey.result.value.path).toBe("deletionKey"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(2); }); test("Should delete a workspace", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace( { displayName: "Foo", }, ctx, ); if (foo.result.case !== "ok") { expect.unreachable(); } if (!foo.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!foo.result.value.workspace.deletionKey?.key) { expect.unreachable("Create handler did not set deleteion_key"); } const bar = await createWorkspace( { displayName: "Bar", }, ctx, ); if (bar.result.case !== "ok") { expect.unreachable(); } if (!bar.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!bar.result.value.workspace.deletionKey?.key) { expect.unreachable("Create handler did not set deleteion_key"); } const deleted = await deleteWorkspace( { id: foo.result.value.workspace.id, deletionKey: foo.result.value.workspace.deletionKey, readMask: { fields: [WorkspaceSchema.field.id.number] }, }, ctx, ); if (deleted.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${deleted.result.case}"`); } expect(deleted.result.value.workspace?.id).toEqual(foo.result.value.workspace.id); expect(deleted.result.value.workspace?.displayName).toBeEmpty(); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Bar"); expect(workspaces.result.value.workspaces[0]?.id).toEqual( bar.result.value.workspace.id, ); });
-
-
-
@@ -1,145 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape } from "@bufbuild/protobuf"; import { type DeleteRequest, DeleteRequestSchema, } from "@yamori/proto/yamori/workspace/v1/delete_request_pb.js"; import { DeleteResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_response_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; function respond(payload: MessageInitShape<typeof DeleteResponseSchema>): Uint8Array { return toBinary(DeleteResponseSchema, create(DeleteResponseSchema, payload)); } /** * `delete` は予約後だからこれだけ目的語がついている。 */ export async function deleteWorkspace( data: Uint8Array, ctx: Context, ): Promise<Uint8Array> { let req: DeleteRequest; try { req = fromBinary(DeleteRequestSchema, data); } catch (error) { return respond({ result: { case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }, }); } if (!req.id?.value) { return respond({ result: { case: "missingField", value: { path: req.id ? "id.value" : "id", }, }, }); } if (!req.deletionKey?.key) { return respond({ result: { case: "capabilityError", value: { path: "deletionKey", }, }, }); } let tx; try { tx = ctx.db.transaction(["workspaces", "paidLeaveProvisionTable"], "readwrite"); const entry = await workspace.getOneById(req.id.value, tx.objectStore("workspaces")); if (!entry) { return respond({ result: { case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }, }); } if (!isSameBytes(req.deletionKey.key, entry.capabilities.deletionKey)) { return respond({ result: { case: "capabilityError", value: { path: "deletionKey", }, }, }); } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); const ws = await workspace.build(entry, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.objectStore("workspaces").delete(req.id.value); const tableKeys = await paidLeaveProvisionTables .index("workspaceId") .getAllKeys(req.id.value); for (const key of tableKeys) { await paidLeaveProvisionTables.delete(key); } await tx.done; return respond({ result: { case: "ok", value: { workspace: ws, }, }, }); } catch (error) { tx?.abort(); return respond({ result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to delete a workspace" : `Exception thrown during delete transaction: ${error}`, }, }, }); } }
-
-
packages/idb_backend/src/yamori/workspace/v1/workspace_service/delete_leave_definition.test.ts (deleted)
-
@@ -1,224 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { DeleteLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_request_pb.js"; import { DeleteLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { idbBackend } from "../../../../lib"; import { type Context } from "../../../../types"; import { CONTEXT } from "../../../../symbols"; import { createWorkspace, getWorkspace } from "./_test_utils"; import { workspaceService } from "./service"; beforeEach(() => { indexedDB = new IDBFactory(); }); async function createLeaveDefinition( ctx: Context, workspace: MessageInitShape<typeof WorkspaceSchema>, ) { const resp = fromBinary( CreateLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", data: toBinary( CreateLeaveDefinitionRequestSchema, create(CreateLeaveDefinitionRequestSchema, { workspaceId: workspace.id, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, leaveDefinition: { displayName: "Foo", revisions: [ { startAt: { year: 2000, month: 1, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, }), ), }, ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable( `Expected "ok" for leave definition creation, got ${JSON.stringify(resp.result.case)}`, ); } return resp.result.value; } async function deleteLeaveDefinition( ctx: Context, request: MessageInitShape<typeof DeleteLeaveDefinitionRequestSchema>, ): Promise<MessageShape<typeof DeleteLeaveDefinitionResponseSchema>> { return fromBinary( DeleteLeaveDefinitionResponseSchema, await workspaceService( { service: "yamori.workspace.v1.WorkspaceService", method: "DeleteLeaveDefinition", data: toBinary( DeleteLeaveDefinitionRequestSchema, create(DeleteLeaveDefinitionRequestSchema, request), ), }, ctx, ), ); } test("Should return missing_field if workspace_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { leaveDefinitionId: def.id, deletionKey: def.deletionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("workspace_id"); }); test("Should return missing_field if leave_definition_id is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, deletionKey: def.deletionKey, }); if (resp.result.case !== "missingField") { expect.unreachable( `Expected "missing_field", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("leave_definition_id"); }); test("Should return not_found if workspace_id does not match", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: { value: workspace.id?.value + "-xxx" }, leaveDefinitionId: def.id, deletionKey: def.deletionKey, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.workspace.v1.Workspace"); }); test("Should return not_found if leave_definition_id does not match", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinitionId: { value: def.id + "-xxx" }, deletionKey: def.deletionKey, }); if (resp.result.case !== "notFound") { expect.unreachable(`Expected "not_found", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.typeName).toBe("yamori.work_record.v1.Leave"); }); test("Should check deletion_key", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinitionId: def.id, deletionKey: { key: new Uint8Array([0, 0, 0]) }, }); if (resp.result.case !== "capabilityError") { expect.unreachable( `Expected "capability_error", got ${JSON.stringify(resp.result.case)}`, ); } expect(resp.result.value.path).toBe("deletion_key"); }); test("Should delete a leave definition", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace(ctx); const def = await createLeaveDefinition(ctx, workspace); const resp = await deleteLeaveDefinition(ctx, { workspaceId: workspace.id, leaveDefinitionId: def.id, deletionKey: def.deletionKey, readMask: { fields: [LeaveSchema.field.id.number] }, }); if (resp.result.case !== "ok") { expect.unreachable(`Expected "ok", got ${JSON.stringify(resp.result.case)}`); } expect(resp.result.value.deleted?.id).not.toBeEmpty(); expect(resp.result.value.deleted?.currentRevision).toBeEmpty(); const workspaceAfter = await getWorkspace(ctx, workspace.id); expect(workspaceAfter.leaveDefinitions.length).toEqual( workspace.leaveDefinitions.length, ); });
-
-
-
@@ -1,153 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageShape, type MessageInitShape, } from "@bufbuild/protobuf"; import { DeleteLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_request_pb.js"; import { DeleteLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_response_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context } from "../../../../types"; import * as leave from "../../../work_record/v1/leave"; import * as workspace from "../workspace"; function respond( result: Extract< MessageInitShape<typeof DeleteLeaveDefinitionResponseSchema>["result"], { case: string } >, ): Uint8Array { return toBinary( DeleteLeaveDefinitionResponseSchema, create(DeleteLeaveDefinitionResponseSchema, { result, }), ); } export async function deleteLeaveDefinition( data: Uint8Array, { db }: Context, ): Promise<Uint8Array> { let req: MessageShape<typeof DeleteLeaveDefinitionRequestSchema>; try { req = fromBinary(DeleteLeaveDefinitionRequestSchema, data); } catch (error) { return respond({ case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }); } if (!req.workspaceId?.value) { return respond({ case: "missingField", value: { path: req.workspaceId ? "workspace_id.value" : "workspace_id", }, }); } if (!req.leaveDefinitionId?.value) { return respond({ case: "missingField", value: { path: req.leaveDefinitionId ? "leave_definition_id.value" : "leave_definition_id", }, }); } if (!req.deletionKey?.key) { return respond({ case: "missingField", value: { path: req.deletionKey ? "deletion_key.value" : "deletion_key", }, }); } let tx; try { tx = db.transaction("workspaces", "readwrite"); const store = tx.objectStore("workspaces"); const found = await workspace.getOneById(req.workspaceId.value, store); if (!found) { await tx.done; return respond({ case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }); } const def = found.leaveDefinitions.find( (def) => def.id === req.leaveDefinitionId?.value, ); if (!def) { await tx.done; return respond({ case: "notFound", value: { typeName: "yamori.work_record.v1.Leave", }, }); } const value = leave.build(def, { mask: req.readMask, }); if (!def.deletionKey || !isSameBytes(req.deletionKey.key, def.deletionKey)) { await tx.done; return respond({ case: "capabilityError", value: { path: "deletion_key", }, }); } await store.put({ ...found, leaveDefinitions: found.leaveDefinitions.filter( (def) => def.id !== req.leaveDefinitionId?.value, ), }); await tx.done; return respond({ case: "ok", value: { deleted: value, }, }); } catch (error) { tx?.abort(); return respond({ case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to delete a leave definition" : `Exception thrown during delete transaction: ${error}`, }, }); } }
-
-
-
@@ -1,182 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { create as createHandler } from "./create"; import { get } from "./get"; beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return an error response on DB error", async () => { const resp = fromBinary( GetResponseSchema, await get( toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: { value: "ws-foo", }, }), ), { db: await openDB("test"), }, ), ); if (resp.result.case !== "systemError") { expect.unreachable(); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); test("Should return workspace matches to the ID", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const add = async (displayName: string) => { return fromBinary( CreateResponseSchema, await createHandler( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName, }), ), ctx, ), ); }; const foo = await add("Foo"); if (foo.result.case !== "ok") { expect.unreachable(); } const bar = await add("Bar"); if (bar.result.case !== "ok") { expect.unreachable(); } const resp = fromBinary( GetResponseSchema, await get( toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: foo.result.value.workspace?.id, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.id).toEqual(foo.result.value.workspace!.id!); expect(resp.result.value.displayName).toBe("Foo"); expect(resp.result.value.updateKey?.key).toBeInstanceOf(Uint8Array); expect(resp.result.value.deletionKey?.key).toBeInstanceOf(Uint8Array); expect(resp.result.value.workerAddKey?.key).toBeInstanceOf(Uint8Array); expect(resp.result.value.abbreviations).not.toBeEmpty(); }); test("Should mask response", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const add = async (displayName: string) => { return fromBinary( CreateResponseSchema, await createHandler( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName, }), ), ctx, ), ); }; const foo = await add("Foo"); if (foo.result.case !== "ok") { expect.unreachable(); } const resp1 = fromBinary( GetResponseSchema, await get( toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: foo.result.value.workspace?.id, readMask: { fields: [WorkspaceSchema.field.displayName.number], }, }), ), ctx, ), ); if (resp1.result.case !== "ok") { expect.unreachable(); } expect(resp1.result.value.id).toBeEmpty(); expect(resp1.result.value.displayName).toBe("Foo"); expect(resp1.result.value.updateKey).toBeEmpty(); expect(resp1.result.value.deletionKey).toBeEmpty(); expect(resp1.result.value.workerAddKey).toBeEmpty(); const resp2 = fromBinary( GetResponseSchema, await get( toBinary( GetRequestSchema, create(GetRequestSchema, { workspaceId: foo.result.value.workspace?.id, readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.deletionKey.number, ], }, }), ), ctx, ), ); if (resp2.result.case !== "ok") { expect.unreachable(); } expect(resp2.result.value.id).toEqual(foo.result.value.workspace!.id!); expect(resp2.result.value.displayName).toBeEmpty(); expect(resp2.result.value.updateKey).toBeEmpty(); expect(resp2.result.value.deletionKey?.key).toBeInstanceOf(Uint8Array); expect(resp2.result.value.workerAddKey).toBeEmpty(); });
-
-
-
@@ -1,111 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { GetRequestSchema, type GetRequest, } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { type Context } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; export async function get(data: Uint8Array, ctx: Context): Promise<Uint8Array> { let req: GetRequest; try { req = fromBinary(GetRequestSchema, data); } catch (error) { return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }, }), ); } if (!req.workspaceId?.value) { return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "missingField", value: { path: "workspace_id", }, }, }), ); } try { const tx = ctx.db.transaction(["workspaces", "paidLeaveProvisionTable"], "readonly"); const found = await tx.objectStore("workspaces").get(req.workspaceId.value); if (!found) { return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }, }), ); } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); const ws = await workspace.build(found, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.done; return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "ok", value: ws, }, }), ); } catch (error) { return toBinary( GetResponseSchema, create(GetResponseSchema, { result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to query workspace" : `Exception thrown during get operation: ${error}`, }, }, }), ); } }
-
-
-
@@ -1,190 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { create as createHandler } from "./create"; import { list } from "./list"; beforeEach(() => { indexedDB = new IDBFactory(); }); test("Should return an error response on read error", async () => { const resp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), { db: await openDB("test"), }), ); if (resp.result.case !== "systemError") { expect.unreachable(); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); test("Should return an empty workspaces on clean slate", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const resp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), ctx), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspaces).toEqual([]); }); function sleep() { return new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, 1); }); } test("Should return workspaces in descending order", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const add = async (displayName: string) => { return fromBinary( CreateResponseSchema, await createHandler( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName, }), ), ctx, ), ); }; const resp1 = await add("Foo"); if (resp1.result.case !== "ok") { expect.unreachable(); } // スリープしないとタイムスタンプが同じになってしまう。 // 実際にこのくらいの短い間隔で来たリクエストは同時と扱って問題ない。 await sleep(); const resp2 = await add("Bar"); if (resp2.result.case !== "ok") { expect.unreachable(); } await sleep(); const resp3 = await add("Baz"); if (resp3.result.case !== "ok") { expect.unreachable(); } const resp = fromBinary( ListResponseSchema, await list(toBinary(ListRequestSchema, create(ListRequestSchema)), ctx), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspaces).toHaveLength(3); expect(resp.result.value.workspaces[0]!.displayName).toBe("Baz"); expect(resp.result.value.workspaces[1]!.displayName).toBe("Bar"); expect(resp.result.value.workspaces[2]!.displayName).toBe("Foo"); }); test("Should mask output fields", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const add = async (displayName: string) => { return fromBinary( CreateResponseSchema, await createHandler( toBinary( CreateRequestSchema, create(CreateRequestSchema, { displayName, }), ), ctx, ), ); }; const foo = await add("Foo"); if (foo.result.case !== "ok") { expect.unreachable(); } await sleep(); const bar = await add("Bar"); if (bar.result.case !== "ok") { expect.unreachable(); } await sleep(); const baz = await add("Baz"); if (baz.result.case !== "ok") { expect.unreachable(); } const resp = fromBinary( ListResponseSchema, await list( toBinary( ListRequestSchema, create(ListRequestSchema, { readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.displayName.number, ], }, }), ), ctx, ), ); if (resp.result.case !== "ok") { expect.unreachable(); } expect(resp.result.value.workspaces).toHaveLength(3); expect(resp.result.value.workspaces[0]!.id).not.toBeEmpty(); expect(resp.result.value.workspaces[0]!.displayName).toBe("Baz"); expect(resp.result.value.workspaces[0]!.updateKey).toBeEmpty(); expect(resp.result.value.workspaces[1]!.id).not.toBeEmpty(); expect(resp.result.value.workspaces[1]!.displayName).toBe("Bar"); expect(resp.result.value.workspaces[1]!.deletionKey).toBeEmpty(); expect(resp.result.value.workspaces[2]!.id).not.toBeEmpty(); expect(resp.result.value.workspaces[2]!.displayName).toBe("Foo"); expect(resp.result.value.workspaces[2]!.leaveDefinitions).toBeEmpty(); });
-
-
-
@@ -1,107 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { ListRequestSchema, type ListRequest, } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type Context } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; export async function list(data: Uint8Array, { db }: Context): Promise<Uint8Array> { let req: ListRequest; try { req = fromBinary(ListRequestSchema, data); } catch (error) { return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }, }), ); } try { const tx = db.transaction(["workspaces", "paidLeaveProvisionTable"], "readonly"); const index = tx.objectStore("workspaces").index("updatedAt"); const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); const workspaces: Workspace[] = []; for await (const cursor of index.iterate(null, "prev")) { workspaces.push( await workspace.build(cursor.value, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }), ); } await tx.done; return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "ok", value: { workspaces }, }, }), ); } catch (error) { if (error instanceof DOMException) { return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to access workspace data store" : `Exception thrown during access to IndexedDB: ${error}`, }, }, }), ); } return toBinary( ListResponseSchema, create(ListResponseSchema, { result: { case: "systemError", value: { code: "", message: import.meta.env.NODE_ENV === "production" ? "Unexpected error" : `Unexpected exception thrown: ${error}`, }, }, }), ); } }
-
-
-
@@ -1,36 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Context, type RPCMessage } from "../../../../types"; import { create } from "./create"; import { createLeaveDefinition } from "./create_leave_definition"; import { deleteLeaveDefinition } from "./delete_leave_definition"; import { deleteWorkspace } from "./delete"; import { get } from "./get"; import { list } from "./list"; import { update } from "./update"; export async function workspaceService( request: RPCMessage, ctx: Context, ): Promise<Uint8Array> { switch (request.method) { case "List": return list(request.data, ctx); case "Create": return create(request.data, ctx); case "Update": return update(request.data, ctx); case "Delete": return deleteWorkspace(request.data, ctx); case "Get": return get(request.data, ctx); case "CreateLeaveDefinition": return createLeaveDefinition(request.data, ctx); case "DeleteLeaveDefinition": return deleteLeaveDefinition(request.data, ctx); default: throw new Error(`Unknown method "${request.method}" for ${request.service}`); } }
-
-
-
@@ -1,514 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "fake-indexeddb/auto"; import { beforeEach, expect, test } from "bun:test"; import { create, fromBinary, toBinary, type MessageInitShape, type DescMessage, type MessageShape, } from "@bufbuild/protobuf"; import { CreateRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/workspace/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/workspace/v1/list_response_pb.js"; import { UpdateRequestSchema } from "@yamori/proto/yamori/workspace/v1/update_request_pb.js"; import { UpdateResponseSchema } from "@yamori/proto/yamori/workspace/v1/update_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { idbBackend } from "../../../../lib"; import { CONTEXT } from "../../../../symbols"; import { type Context } from "../../../../types"; import { create as createHandler } from "./create"; import { list } from "./list"; import { update } from "./update"; beforeEach(() => { indexedDB = new IDBFactory(); }); function bind<RequestSchema extends DescMessage, ResponseSchema extends DescMessage>( requestSchema: RequestSchema, responseSchema: ResponseSchema, handler: (req: Uint8Array, ctx: Context) => Promise<Uint8Array>, ): ( req: MessageInitShape<RequestSchema>, ctx: Context, ) => Promise<MessageShape<ResponseSchema>> { return async (request, ctx) => fromBinary( responseSchema, await handler(toBinary(requestSchema, create(requestSchema, request)), ctx), ); } const createWorkspace = bind(CreateRequestSchema, CreateResponseSchema, createHandler); const listWorkspaces = bind(ListRequestSchema, ListResponseSchema, list); const updateWorkspace = bind(UpdateRequestSchema, UpdateResponseSchema, update); test("Should return an error on IndexedDB exceptions", async () => { const resp = await updateWorkspace( { id: { value: "foo-bar", }, updateKey: { key: new Uint8Array([]), }, displayName: "Foo", fieldMask: { paths: ["displayName"], }, }, { db: await openDB("test"), }, ); if (resp.result.case !== "systemError") { expect.unreachable(`Expected "systemError", found "${resp.result.case}"`); } expect(resp.result.value.code).toBe("IDB_ERROR"); }); test("Should return a missing field error if ID is nonexistent", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!created.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const updated = await updateWorkspace( { id: { value: created.result.value.workspace.id.value + "-1111", }, updateKey: created.result.value.workspace.updateKey, displayName: "Bar", fieldMask: { paths: ["displayName"], }, }, ctx, ); if (updated.result.case !== "notFound") { expect.unreachable(`Expected "not_found", found "${updated.result.case}"`); } const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Foo"); }); test("Should return an error if ID field is absent", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!created.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const updated = await updateWorkspace( { displayName: "Bar", updateKey: created.result.value.workspace.updateKey, fieldMask: { paths: ["displayName"], }, }, ctx, ); if (updated.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found "${updated.result.case}"`); } expect(updated.result.value.path).toBe("id"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Foo"); }); test("Should return an error if display_name is in field_mask but missing", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!created.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const updated = await updateWorkspace( { id: { value: created.result.value.workspace.id.value, }, updateKey: created.result.value.workspace.updateKey, fieldMask: { paths: ["display_name"], }, }, ctx, ); if (updated.result.case !== "missingField") { expect.unreachable(`Expected "missing_field", found "${updated.result.case}"`); } expect(updated.result.value.path).toBe("display_name"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(1); expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Foo"); }); test("Should do nothing if field_mask is empty", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const created = await createWorkspace( { displayName: "Foo", }, ctx, ); if (created.result.case !== "ok") { expect.unreachable(); } if (!created.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!created.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const updated = await updateWorkspace( { id: { value: created.result.value.workspace.id.value, }, updateKey: created.result.value.workspace.updateKey, displayName: "Bar", }, ctx, ); if (updated.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${updated.result.case}"`); } expect(updated.result.value.workspace?.displayName).toBe("Foo"); }); test("Should return an error for insufficient capability", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace( { displayName: "Foo", }, ctx, ); if (foo.result.case !== "ok") { expect.unreachable(); } if (!foo.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!foo.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const bar = await createWorkspace( { displayName: "Bar", }, ctx, ); if (bar.result.case !== "ok") { expect.unreachable(); } if (!bar.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!bar.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } // キーなし const missingKey = await updateWorkspace( { id: foo.result.value.workspace.id, displayName: "Baz", fieldMask: { paths: ["display_name"], }, }, ctx, ); if (missingKey.result.case !== "capabilityError") { expect.unreachable(`Expected "capabilityError", found "${missingKey.result.case}"`); } expect(missingKey.result.value.path).toBe("updateKey"); // 別のワークスペースのキー const invalidKey = await updateWorkspace( { id: foo.result.value.workspace.id, updateKey: bar.result.value.workspace.updateKey, displayName: "Baz", fieldMask: { paths: ["display_name"], }, }, ctx, ); if (invalidKey.result.case !== "capabilityError") { expect.unreachable(`Expected "capabilityError", found "${invalidKey.result.case}"`); } expect(invalidKey.result.value.path).toBe("updateKey"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect( workspaces.result.value.workspaces.find( (workspace) => workspace.displayName === "Baz", ), ).toBeEmpty(); }); function sleep() { return new Promise<void>((resolve) => { setTimeout(() => void resolve(), 1); }); } test("Should update a workspace", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace( { displayName: "Foo", }, ctx, ); if (foo.result.case !== "ok") { expect.unreachable(); } if (!foo.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!foo.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } // アサーションを簡単にするため、タイムスタンプをずらして順序を付けている await sleep(); const baz = await createWorkspace( { displayName: "Baz", }, ctx, ); if (baz.result.case !== "ok") { expect.unreachable(); } if (!baz.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!baz.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } // 更新によってタイムスタンプが変わることを確認するため、スリープの必要がある await sleep(); const updated = await updateWorkspace( { id: { value: foo.result.value.workspace.id.value, }, updateKey: foo.result.value.workspace.updateKey, displayName: "Bar", fieldMask: { paths: ["display_name"], }, }, ctx, ); if (updated.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${updated.result.case}"`); } expect(updated.result.value.workspace?.displayName).toBe("Bar"); const workspaces = await listWorkspaces({}, ctx); if (workspaces.result.case !== "ok") { expect.unreachable(); } expect(workspaces.result.value.workspaces).toHaveLength(2); // foo は先に作成されたが、後に更新されたため降順のトップに来る expect(workspaces.result.value.workspaces[0]?.displayName).toBe("Bar"); expect(workspaces.result.value.workspaces[0]?.id?.value).toBe( foo.result.value.workspace.id.value, ); expect(workspaces.result.value.workspaces[1]?.displayName).toBe("Baz"); expect(workspaces.result.value.workspaces[1]?.id?.value).toBe( baz.result.value.workspace.id.value, ); }); test("Should respect read_mask", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const foo = await createWorkspace( { displayName: "Foo", }, ctx, ); if (foo.result.case !== "ok") { expect.unreachable(); } if (!foo.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!foo.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const updated = await updateWorkspace( { id: { value: foo.result.value.workspace.id.value, }, updateKey: foo.result.value.workspace.updateKey, displayName: "Bar", fieldMask: { paths: ["display_name"], }, readMask: { fields: [WorkspaceSchema.field.displayName.number], }, }, ctx, ); if (updated.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${updated.result.case}"`); } expect(updated.result.value.workspace?.displayName).toBe("Bar"); expect(updated.result.value.workspace?.id).toBeEmpty(); }); test("Should update abbreviations", async () => { const { [CONTEXT]: ctx } = await idbBackend(); const workspace = await createWorkspace( { displayName: "Foo", }, ctx, ); if (workspace.result.case !== "ok") { expect.unreachable(); } if (!workspace.result.value.workspace?.id?.value) { expect.unreachable("Create handler assigned an empty ID"); } if (!workspace.result.value.workspace.updateKey?.key) { expect.unreachable("Create handler did not set update_key"); } const updated = await updateWorkspace( { id: { value: workspace.result.value.workspace.id.value, }, updateKey: workspace.result.value.workspace.updateKey, abbreviations: { dayoff: "D1", paidLeave: "D2", worked: "D3", }, fieldMask: { paths: ["abbreviations.dayoff", "abbreviations.paid_leave"], }, }, ctx, ); if (updated.result.case !== "ok") { expect.unreachable(`Expected "ok", found "${updated.result.case}"`); } expect(updated.result.value.workspace?.abbreviations?.dayoff).toBe("D1"); expect(updated.result.value.workspace?.abbreviations?.paidLeave).toBe("D2"); expect(updated.result.value.workspace?.abbreviations?.worked).not.toBe("D3"); });
-
-
-
@@ -1,303 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, fromBinary, toBinary, type MessageInitShape } from "@bufbuild/protobuf"; import { type UpdateRequest, UpdateRequestSchema, } from "@yamori/proto/yamori/workspace/v1/update_request_pb.js"; import { UpdateResponseSchema } from "@yamori/proto/yamori/workspace/v1/update_response_pb.js"; import { isSameBytes } from "../../../../helpers"; import { type Context, type YamoriDB } from "../../../../types"; import * as paidLeaveProvisionTable from "../../../paid_leave_provision/v1/paid_leave_provision_table"; import * as workspace from "../workspace"; function respond(payload: MessageInitShape<typeof UpdateResponseSchema>): Uint8Array { return toBinary(UpdateResponseSchema, create(UpdateResponseSchema, payload)); } export async function update(data: Uint8Array, { db }: Context): Promise<Uint8Array> { let req: UpdateRequest; try { req = fromBinary(UpdateRequestSchema, data); } catch (error) { return respond({ result: { case: "systemError", value: { code: "INVALID_MESSAGE", message: import.meta.env.NODE_ENV === "production" ? "Failed to parse request message" : `Request message does not conform schema: ${error}`, }, }, }); } if (!req.id?.value) { return respond({ result: { case: "missingField", value: { path: req.id ? "id.value" : "id", }, }, }); } if (!req.updateKey?.key) { return respond({ result: { case: "capabilityError", value: { path: "updateKey", }, }, }); } const changes: (( prev: YamoriDB["workspaces"]["value"], ) => YamoriDB["workspaces"]["value"])[] = []; for (const path of req.fieldMask?.paths ?? []) { switch (path) { case "display_name": { if (!req.displayName) { return respond({ result: { case: "missingField", value: { path: "display_name", }, }, }); } changes.push((prev) => ({ ...prev, displayName: req.displayName })); break; } case "abbreviations.dayoff": { const value = req.abbreviations?.dayoff; if (!value) { return respond({ result: { case: "missingField", value: { path: "abbreviations.dayoff", }, }, }); } changes.push((prev) => ({ ...prev, abbreviations: { ...prev.abbreviations, dayoff: value }, })); break; } case "abbreviations.worked": { const value = req.abbreviations?.worked; if (!value) { return respond({ result: { case: "missingField", value: { path: "abbreviations.worked", }, }, }); } changes.push((prev) => ({ ...prev, abbreviations: { ...prev.abbreviations, worked: value }, })); break; } case "abbreviations.skip_work": { const value = req.abbreviations?.skipWork; if (!value) { return respond({ result: { case: "missingField", value: { path: "abbreviations.skip_work", }, }, }); } changes.push((prev) => ({ ...prev, abbreviations: { ...prev.abbreviations, skippedWork: value }, })); break; } case "abbreviations.paid_leave": { const value = req.abbreviations?.paidLeave; if (!value) { return respond({ result: { case: "missingField", value: { path: "abbreviations.paid_leave", }, }, }); } changes.push((prev) => ({ ...prev, abbreviations: { ...prev.abbreviations, paidLeave: value }, })); break; } default: // 前方・後方互換性のために不明なフィールドは無視する break; } } let tx; try { tx = db.transaction(["workspaces", "paidLeaveProvisionTable"], "readwrite"); } catch (error) { return respond({ result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to begin transaction" : `Exception thrown while beginning transaction: ${error}`, }, }, }); } let found: YamoriDB["workspaces"]["value"]; try { const entry = await tx.objectStore("workspaces").get(req.id.value); if (!entry) { return respond({ result: { case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }, }); } found = entry; } catch (error) { return respond({ result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to read current workspace data" : `Exception thrown while reading current workspace: ${error}`, }, }, }); } if ( !(found.capabilities.updateKey instanceof Uint8Array) || !isSameBytes(req.updateKey.key, found.capabilities.updateKey) ) { return respond({ result: { case: "capabilityError", value: { path: "updateKey", }, }, }); } const plptCache = paidLeaveProvisionTable.createCache(); const paidLeaveProvisionTables = tx.objectStore("paidLeaveProvisionTable"); if (!changes.length) { return respond({ result: { case: "ok", value: { workspace: await workspace.build(found, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }), }, }, }); } const payload = changes.reduce((prev, f) => f(prev), found); try { const store = tx.objectStore("workspaces"); const key = await store.put({ ...payload, updatedAt: Date.now(), }); const updated = await store.get(key); if (!updated) { throw "Failed to query an updated entry"; } const ws = await workspace.build(updated, { mask: req.readMask, getPaidLeaveProvisionTables(workspaceId) { return paidLeaveProvisionTable.getAllFor({ workspaceId, cache: plptCache, store: paidLeaveProvisionTables, }); }, }); await tx.done; return respond({ result: { case: "ok", value: { workspace: ws, }, }, }); } catch (error) { return respond({ result: { case: "systemError", value: { code: "IDB_ERROR", message: import.meta.env.NODE_ENV === "production" ? "Failed to write workspace" : `Exception thrown while writing workspace: ${error}`, }, }, }); } }
-
-
-
@@ -1,15 +0,0 @@// ビルドする際の設定。 // // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, "declaration": true, "incremental": true, "outDir": "./lib" }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.test.ts"] }
-
-
packages/idb_backend/tsconfig.json (deleted)
-
@@ -1,10 +0,0 @@{ "extends": "../../tsconfig.jsonc", "compilerOptions": { "allowImportingTsExtensions": false, "moduleResolution": "Bundler", "noEmit": true, "lib": ["ES2020", "WebWorker"] }, "include": ["src/**/*.ts"] }
-
-
-
@@ -1,9 +0,0 @@TypeScript のコンパイラ設定ファイル。 実際は JSON ではなく JSONC のためコメントを含められるが、 .jsonc にすると TypeScript 関連のツールがデフォルトで読まなくなる。逆に .json のまま コメントを含めるとフォーマッタといった TypeScript 以外の JSON を扱うツール でエラーになる。そのため拡張子と仕様に準拠している。 SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/proto/.gitignore (deleted)
-
@@ -1,12 +0,0 @@# このディレクトリ特有の無視設定。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only # What: 自動生成された .js/.dts ファイルの格納先。 # Why: `yamori/` ディレクトリ配下の .proto ファイルから自動生成されるため。 /es # What: 自動生成された .go ファイルの格納先。 # Why: `yamori/` ディレクトリ配下の .proto ファイルから自動生成されるため。 /go
-
-
packages/proto/README.md (deleted)
-
@@ -1,60 +0,0 @@<!-- SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only --> # `proto` このプロジェクトで取り扱う Protocol Buffers のデータ定義が全てまとまっているディレクトリ。 ## 利用方法 ### JavaScript API 依存パッケージとして [`@bufbuild/protobuf`](https://www.npmjs.com/package/@bufbuild/protobuf) が必要。 `peerDependencies` にバージョン要件があるため確認すること。 Protobuf のメッセージやサービス定義のファイルが 1:1 で JS/DTS に変換されており、 `@yamori/proto/path/to/file_pb.js` としてアクセスできる。 例えば以下のような Protobuf の定義ファイルがあった場合、 ```protobuf # packages/proto/yamori/foo/bar.proto package yamori.foo.bar; message Bar { string value = 1; } ``` 以下のように `import` できる。 ```ts import { fromBinary } from "@bufbuild/protobuf"; import { type Bar, BarSchema } from "@yamori/proto/yamori/foo/bar_pb.js"; export function decodeBar(bytes: Uint8Array): Bar { return fromBinary(BarSchema, bytes); } ``` ## 開発 このパッケージ自体の開発を行う場合のドキュメント。 ### 品質チェック このプロジェクトでは [Buf CLI](https://buf.build/docs/cli/) を使って Protobuf ファイルの品質をチェックしている。以下のコマンドでチェックを実行できる。 ``` $ bun check ``` ### コード生成 Protobuf データのデコード・エンコードを行うコードを生成するには以下のコマンドを実行する。 ``` $ bun make ```
-
-
packages/proto/REUSE.toml (deleted)
-
@@ -1,9 +0,0 @@# SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only version = 1 [[annotations]] path = "google/**/*" SPDX-FileCopyrightText = "Copyright 2008 Google Inc. All rights reserved." SPDX-License-Identifier = "BSD-3-Clause"
-
-
packages/proto/buf.gen.yaml (deleted)
-
@@ -1,26 +0,0 @@# `buf generate` を実行する際の設定。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only version: v2 plugins: - local: protoc-gen-es out: es opt: - target=js+dts - import_extension=js - local: protoc-gen-go out: go opt: - module=pocka.jp/x/yamori/proto/go - local: protoc-gen-connect-go out: go opt: - module=pocka.jp/x/yamori/proto/go inputs: - directory: . exclude_paths: - google
-
-
packages/proto/buf.yaml (deleted)
-
@@ -1,10 +0,0 @@# buf の全般設定。 # コード生成に関しては `buf.gen.yaml` を参照。 # # SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only version: v2 lint: enum_zero_value_suffix: _UNKNOWN
-
-
packages/proto/go.mod (deleted)
-
@@ -1,13 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only module pocka.jp/x/yamori/proto go 1.24.1 require ( connectrpc.com/connect v1.18.1 google.golang.org/protobuf v1.36.5 ) require github.com/google/go-cmp v0.6.0 // indirect
-
-
packages/proto/go.sum (deleted)
-
@@ -1,5 +0,0 @@connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
-
-
-
@@ -1,245 +0,0 @@// Protocol Buffers - Google's data interchange format // Copyright 2008 Google Inc. All rights reserved. // https://developers.google.com/protocol-buffers/ // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are // met: // // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above // copyright notice, this list of conditions and the following disclaimer // in the documentation and/or other materials provided with the // distribution. // * Neither the name of Google Inc. nor the names of its // contributors may be used to endorse or promote products derived from // this software without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. syntax = "proto3"; package google.protobuf; option cc_enable_arenas = true; option csharp_namespace = "Google.Protobuf.WellKnownTypes"; option go_package = "google.golang.org/protobuf/types/known/fieldmaskpb"; option java_multiple_files = true; option java_outer_classname = "FieldMaskProto"; option java_package = "com.google.protobuf"; option objc_class_prefix = "GPB"; // `FieldMask` represents a set of symbolic field paths, for example: // // paths: "f.a" // paths: "f.b.d" // // Here `f` represents a field in some root message, `a` and `b` // fields in the message found in `f`, and `d` a field found in the // message in `f.b`. // // Field masks are used to specify a subset of fields that should be // returned by a get operation or modified by an update operation. // Field masks also have a custom JSON encoding (see below). // // # Field Masks in Projections // // When used in the context of a projection, a response message or // sub-message is filtered by the API to only contain those fields as // specified in the mask. For example, if the mask in the previous // example is applied to a response message as follows: // // f { // a : 22 // b { // d : 1 // x : 2 // } // y : 13 // } // z: 8 // // The result will not contain specific values for fields x,y and z // (their value will be set to the default, and omitted in proto text // output): // // // f { // a : 22 // b { // d : 1 // } // } // // A repeated field is not allowed except at the last position of a // paths string. // // If a FieldMask object is not present in a get operation, the // operation applies to all fields (as if a FieldMask of all fields // had been specified). // // Note that a field mask does not necessarily apply to the // top-level response message. In case of a REST get operation, the // field mask applies directly to the response, but in case of a REST // list operation, the mask instead applies to each individual message // in the returned resource list. In case of a REST custom method, // other definitions may be used. Where the mask applies will be // clearly documented together with its declaration in the API. In // any case, the effect on the returned resource/resources is required // behavior for APIs. // // # Field Masks in Update Operations // // A field mask in update operations specifies which fields of the // targeted resource are going to be updated. The API is required // to only change the values of the fields as specified in the mask // and leave the others untouched. If a resource is passed in to // describe the updated values, the API ignores the values of all // fields not covered by the mask. // // If a repeated field is specified for an update operation, new values will // be appended to the existing repeated field in the target resource. Note that // a repeated field is only allowed in the last position of a `paths` string. // // If a sub-message is specified in the last position of the field mask for an // update operation, then new value will be merged into the existing sub-message // in the target resource. // // For example, given the target message: // // f { // b { // d: 1 // x: 2 // } // c: [1] // } // // And an update message: // // f { // b { // d: 10 // } // c: [2] // } // // then if the field mask is: // // paths: ["f.b", "f.c"] // // then the result will be: // // f { // b { // d: 10 // x: 2 // } // c: [1, 2] // } // // An implementation may provide options to override this default behavior for // repeated and message fields. // // In order to reset a field's value to the default, the field must // be in the mask and set to the default value in the provided resource. // Hence, in order to reset all fields of a resource, provide a default // instance of the resource and set all fields in the mask, or do // not provide a mask as described below. // // If a field mask is not present on update, the operation applies to // all fields (as if a field mask of all fields has been specified). // Note that in the presence of schema evolution, this may mean that // fields the client does not know and has therefore not filled into // the request will be reset to their default. If this is unwanted // behavior, a specific service may require a client to always specify // a field mask, producing an error if not. // // As with get operations, the location of the resource which // describes the updated values in the request message depends on the // operation kind. In any case, the effect of the field mask is // required to be honored by the API. // // ## Considerations for HTTP REST // // The HTTP kind of an update operation which uses a field mask must // be set to PATCH instead of PUT in order to satisfy HTTP semantics // (PUT must only be used for full updates). // // # JSON Encoding of Field Masks // // In JSON, a field mask is encoded as a single string where paths are // separated by a comma. Fields name in each path are converted // to/from lower-camel naming conventions. // // As an example, consider the following message declarations: // // message Profile { // User user = 1; // Photo photo = 2; // } // message User { // string display_name = 1; // string address = 2; // } // // In proto a field mask for `Profile` may look as such: // // mask { // paths: "user.display_name" // paths: "photo" // } // // In JSON, the same mask is represented as below: // // { // mask: "user.displayName,photo" // } // // # Field Masks and Oneof Fields // // Field masks treat fields in oneofs just as regular fields. Consider the // following message: // // message SampleMessage { // oneof test_oneof { // string name = 4; // SubMessage sub_message = 9; // } // } // // The field mask can be: // // mask { // paths: "name" // } // // Or: // // mask { // paths: "sub_message" // } // // Note that oneof type names ("test_oneof" in this case) cannot be used in // paths. // // ## Field Mask Verification // // The implementation of any API method which has a FieldMask type field in the // request should verify the included field paths, and return an // `INVALID_ARGUMENT` error if any path is unmappable. message FieldMask { // The set of field mask paths. repeated string paths = 1; }
-
-
-
@@ -1,144 +0,0 @@// Protocol Buffers - Google's data interchange format // Copyright 2008 Google Inc. All rights reserved. // https://developers.google.com/protocol-buffers/ // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are // met: // // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above // copyright notice, this list of conditions and the following disclaimer // in the documentation and/or other materials provided with the // distribution. // * Neither the name of Google Inc. nor the names of its // contributors may be used to endorse or promote products derived from // this software without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. syntax = "proto3"; package google.protobuf; option cc_enable_arenas = true; option csharp_namespace = "Google.Protobuf.WellKnownTypes"; option go_package = "google.golang.org/protobuf/types/known/timestamppb"; option java_multiple_files = true; option java_outer_classname = "TimestampProto"; option java_package = "com.google.protobuf"; option objc_class_prefix = "GPB"; // A Timestamp represents a point in time independent of any time zone or local // calendar, encoded as a count of seconds and fractions of seconds at // nanosecond resolution. The count is relative to an epoch at UTC midnight on // January 1, 1970, in the proleptic Gregorian calendar which extends the // Gregorian calendar backwards to year one. // // All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap // second table is needed for interpretation, using a [24-hour linear // smear](https://developers.google.com/time/smear). // // The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By // restricting to that range, we ensure that we can convert to and from [RFC // 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings. // // # Examples // // Example 1: Compute Timestamp from POSIX `time()`. // // Timestamp timestamp; // timestamp.set_seconds(time(NULL)); // timestamp.set_nanos(0); // // Example 2: Compute Timestamp from POSIX `gettimeofday()`. // // struct timeval tv; // gettimeofday(&tv, NULL); // // Timestamp timestamp; // timestamp.set_seconds(tv.tv_sec); // timestamp.set_nanos(tv.tv_usec * 1000); // // Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`. // // FILETIME ft; // GetSystemTimeAsFileTime(&ft); // UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; // // // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z // // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z. // Timestamp timestamp; // timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL)); // timestamp.set_nanos((INT32) ((ticks % 10000000) * 100)); // // Example 4: Compute Timestamp from Java `System.currentTimeMillis()`. // // long millis = System.currentTimeMillis(); // // Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000) // .setNanos((int) ((millis % 1000) * 1000000)).build(); // // Example 5: Compute Timestamp from Java `Instant.now()`. // // Instant now = Instant.now(); // // Timestamp timestamp = // Timestamp.newBuilder().setSeconds(now.getEpochSecond()) // .setNanos(now.getNano()).build(); // // Example 6: Compute Timestamp from current time in Python. // // timestamp = Timestamp() // timestamp.GetCurrentTime() // // # JSON Mapping // // In JSON format, the Timestamp type is encoded as a string in the // [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the // format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z" // where {year} is always expressed using four digits while {month}, {day}, // {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional // seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution), // are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone // is required. A proto3 JSON serializer should always use UTC (as indicated by // "Z") when printing the Timestamp type and a proto3 JSON parser should be // able to accept both UTC and other timezones (as indicated by an offset). // // For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past // 01:30 UTC on January 15, 2017. // // In JavaScript, one can convert a Date object to this format using the // standard // [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) // method. In Python, a standard `datetime.datetime` object can be converted // to this format using // [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with // the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use // the Joda Time's [`ISODateTimeFormat.dateTime()`]( // http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime() // ) to obtain a formatter capable of generating timestamps in this format. // message Timestamp { // Represents seconds of UTC time since Unix epoch // 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to // 9999-12-31T23:59:59Z inclusive. int64 seconds = 1; // Non-negative fractions of a second at nanosecond resolution. Negative // second values with fractions must still have non-negative nanos values // that count forward in time. Must be from 0 to 999,999,999 // inclusive. int32 nanos = 2; }
-
-
packages/proto/package.json (deleted)
-
@@ -1,63 +0,0 @@{ "name": "@yamori/proto", "private": true, "scripts": { "make": "wireit", "check": "wireit", "clean": "rm -rf es" }, "wireit": { "make": { "command": "buf generate", "files": ["yamori/**/*.proto", "buf.gen.yaml"], "output": ["es/**", "go/**"], "packageLocks": ["bun.lockb"] }, "js": { "files": ["es/**/*.js"], "dependencies": [ { "script": "make", "cascade": false } ] }, "dts": { "files": ["es/**/*.d.ts"], "dependencies": [ { "script": "make", "cascade": false } ] }, "go": { "files": ["go/**/*.go"], "dependencies": [ { "script": "make", "cascade": false } ] }, "check": { "command": "buf lint --exclude-path google", "files": ["yamori/**/*.proto"], "output": [] } }, "type": "module", "exports": { "./*.js": { "types": "./es/*.d.ts", "default": "./es/*.js" } }, "devDependencies": { "@bufbuild/protoc-gen-es": "^2.2.2", "@bufbuild/protobuf": "^2.2.2" }, "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }
-
-
packages/proto/package.json.license (deleted)
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/proto/yamori/backend/README.md (deleted)
-
@@ -1,11 +0,0 @@<!-- SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only --> # `yamori.backend` バックエンドが利用する内部スキーマ。 他のパッケージから利用されることは想定されていない。 このディレクトリ内で定義されたメッセージは認証や認可に関わるデータも含まれているため、バックエンド外に公開させてはならない。
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.v1; import "yamori/backend/events/workspace/v1/event.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/v1"; message Event { oneof event { yamori.backend.events.workspace.v1.Event workspace_event = 1; } }
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; // 未指定のフィールドは前回のイベントの値、最初のイベントで未指定の // フィールドがある場合はシステムの初期値となる。 message AbbreviationsConfigured { string day_off = 1; string worked = 2; string skip_work = 3; string paid_leave = 4; }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminAccessGranted { string user_id = 1; }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminAccessRevoked { string user_id = 1; }
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminCreationPasswordExpired {}
-
-
-
@@ -1,13 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message AdminCreationPasswordGenerated { bytes password_hash = 1; bytes password_salt = 2; }
-
-
-
@@ -1,13 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message CustomAttributeDefined { string id = 1; string display_name = 2; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message CustomAttributeSet { string custom_attribute_id = 1; string user_id = 2; string value = 3; }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message CustomAttributeUndefined { string id = 1; }
-
-
-
@@ -1,46 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; import "yamori/backend/events/workspace/v1/abbreviations_configured.proto"; import "yamori/backend/events/workspace/v1/admin_access_granted.proto"; import "yamori/backend/events/workspace/v1/admin_access_revoked.proto"; import "yamori/backend/events/workspace/v1/admin_creation_password_expired.proto"; import "yamori/backend/events/workspace/v1/admin_creation_password_generated.proto"; import "yamori/backend/events/workspace/v1/custom_attribute_defined.proto"; import "yamori/backend/events/workspace/v1/custom_attribute_set.proto"; import "yamori/backend/events/workspace/v1/custom_attribute_undefined.proto"; import "yamori/backend/events/workspace/v1/login_jwt_secret_configured.proto"; import "yamori/backend/events/workspace/v1/password_login_configured.proto"; import "yamori/backend/events/workspace/v1/user_created.proto"; import "yamori/backend/events/workspace/v1/user_deleted.proto"; import "yamori/backend/events/workspace/v1/user_permissions_granted.proto"; import "yamori/backend/events/workspace/v1/user_permissions_revoked.proto"; import "yamori/backend/events/workspace/v1/user_updated.proto"; import "yamori/backend/events/workspace/v1/workspace_display_name_set.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message Event { oneof event { UserCreated user_created = 1; AdminAccessGranted admin_access_granted = 2; AdminAccessRevoked admin_access_revoked = 3; PasswordLoginConfigured password_login_configured = 4; AdminCreationPasswordGenerated admin_creation_password_generated = 5; AdminCreationPasswordExpired admin_creation_password_expired = 6; LoginJwtSecretConfigured login_jwt_secret_configured = 7; WorkspaceDisplayNameSet workspace_display_name_set = 8; UserPermissionsGranted user_permissions_granted = 9; UserPermissionsRevoked user_permissions_revoked = 10; AbbreviationsConfigured abbreviations_configured = 11; CustomAttributeDefined custom_attribute_defined = 12; CustomAttributeUndefined custom_attribute_undefined = 13; CustomAttributeSet custom_attribute_set = 14; UserDeleted user_deleted = 15; UserUpdated user_updated = 16; } }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message LoginJwtSecretConfigured { bytes secret = 1; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message PasswordLoginConfigured { string user_id = 1; bytes password_hash = 2; bytes password_salt = 3; }
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserCreated { string id = 1; string name = 2; string display_name = 3; // ユーザ固有の権限キーの生成に用いるバイト列。 bytes key_id = 4; }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserDeleted { string id = 1; }
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; import "yamori/backend/workspace/v1/types/v1/permission.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserPermissionsGranted { string user_id = 1; repeated yamori.backend.workspace.v1.types.v1.Permission permissions = 2; }
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; import "yamori/backend/workspace/v1/types/v1/permission.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserPermissionsRevoked { string user_id = 1; repeated yamori.backend.workspace.v1.types.v1.Permission permissions = 2; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserUpdated { string id = 1; string name = 2; string display_name = 3; }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message WorkspaceDisplayNameSet { string display_name = 1; }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.projections.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1"; message AdminCreationPassword { message Password { bytes hash = 1; bytes salt = 2; } // 存在しない場合もあるため空値を表現できるメッセージにしている。 Password password = 1; }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.projections.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1"; message LoginJwtSecret { bytes secret = 1; }
-
-
-
@@ -1,40 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.projections.workspace.v1; import "yamori/backend/workspace/v1/types/v1/permission.proto"; option go_package = "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1"; // ユーザの一覧。 // ぶっちゃけこれは普通のテーブルにしてもいいが、面倒なので Projection にしてる // パフォーマンスとかの問題が出てきたら Query 用のテーブルを作って Event -> SQL みたいな // Projection テーブルにするものいいかもしれない。クエリ・ストレージパフォーマンスと // マイグレーション容易性の妥協点的な。 message Users { message PasswordLogin { bytes hash = 1; bytes salt = 2; } message User { message CustomAttribute { string id = 1; string value = 2; } string id = 1; string name = 2; string display_name = 3; PasswordLogin password_login = 4; bool is_admin = 5; bytes key_id = 6; repeated yamori.backend.workspace.v1.types.v1.Permission permissions = 7; repeated CustomAttribute custom_attributes = 8; } repeated User users = 1; }
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.projections.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/projections/workspace/v1"; message Workspace { uint32 number_of_admins = 1; string display_name = 2; Abbreviations abbreviations = 3; repeated CustomAttribute custom_attributes = 4; message Abbreviations { string day_off = 1; string worked = 2; string skip_work = 3; string paid_leave = 4; } message CustomAttribute { string id = 1; string display_name = 2; } }
-
-
-
@@ -1,48 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.workspace.v1.types.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types"; enum Permission { PERMISSION_UNKNOWN = 0; // 通常ユーザの追加権限。 PERMISSION_ADD_REGULAR_USER = 1; // 管理者ユーザの追加権限。 PERMISSION_ADD_ADMIN_USER = 9; // 自分以外の通常ユーザの削除権限。 PERMISSION_DELETE_REGULAR_USER = 2; // 自分以外の管理者ユーザの削除権限。 PERMISSION_DELETE_ADMIN_USER = 10; // 自分以外の通常ユーザの基本情報閲覧権限。 PERMISSION_READ_REGULAR_USER_PROFILE = 3; // 自分以外の管理者ユーザの基本情報閲覧権限。 PERMISSION_READ_ADMIN_USER_PROFILE = 4; // 自分以外の通常ユーザの基本情報変更権限。 PERMISSION_UPDATE_REGULAR_USER_PROFILE = 5; // 自分以外の管理者ユーザの基本情報変更権限。 PERMISSION_UPDATE_ADMIN_USER_PROFILE = 11; // 自身の基本情報変更権限。 PERMISSION_UPDATE_SELF_PROFILE = 6; // 自分以外の通常ユーザのログイン手段変更権限。 PERMISSION_UPDATE_REGULAR_USER_LOGIN_METHOD = 7; // 自分以外の管理者ユーザのログイン手段変更権限。 PERMISSION_UPDATE_ADMIN_USER_LOGIN_METHOD = 12; // ワークスペースの設定の変更権限。 PERMISSION_EDIT_WORKSPACE_PROFILE = 8; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.capability.v1; option go_package = "pocka.jp/x/yamori/proto/go/capability/v1"; // アクセス許可キー。 message CapabilityKey { // キーを識別・検証するための任意のバイト列。 bytes key = 1; }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; message AuthenticationError { uint64 retry_after_seconds = 1; }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; message CapabilityError { // エラーの原因となったキーのパス。セキュリティのため空の場合もある。 // 記法は google.protobuf.FieldMask と同様。 // <https://github.com/protocolbuffers/protobuf/blob/5d0865cf1537772b8e0969563402654087b40d31/src/google/protobuf/field_mask.proto> string path = 1; }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; // メッセージにおいて指定が必須のフィールドが指定されていない場合のエラー。 message MissingFieldError { // 必須だが空のフィールドのパス。記法は google.protobuf.FieldMask と同様。 // <https://github.com/protocolbuffers/protobuf/blob/5d0865cf1537772b8e0969563402654087b40d31/src/google/protobuf/field_mask.proto> string path = 1; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; // データをこれ以上保存できない場合のエラー。 message NoStorageSpace { // デバッグ・調査用のメッセージ。 string message = 1; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; // 対象のリソースが存在しない場合のエラー。 message NotFound { // リソースの型名。 string type_name = 1; }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; // メソッドが未実装の際のエラー。 message NotImplemented { // 開発者向けの詳細。バックエンドで既知のメソッドを実装できない場合 // などに理由のメッセージが入る。 string details = 1; // 対象のメソッド。 string method = 2; }
-
-
-
@@ -1,11 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; // 認可エラー。ログインユーザには対象の操作を行う権限がない。 message PermissionError {}
-
-
-
@@ -1,21 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.error.v1; option go_package = "pocka.jp/x/yamori/proto/go/error/v1"; // 予期せぬエラー。事前に定義されていないエラーは全てこのメッセージ // で表される。また、プラットフォーム・配布形態固有のため各サービス // で定義できないものもこれで表される。 message SystemError { // エラーを識別するためのシステム文字列。デバッグ・調査時の補助目的。 string code = 1; // デバッグ・調査用のエラーメッセージ。システム的なものであるため // ユーザに表示する内容ではない。完全に原因不明のクラッシュなどで // メッセージが存在しない場合もある。 string message = 2; }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.idempotency.v1; option go_package = "pocka.jp/x/yamori/proto/go/idempotency/v1"; // 冪等性を確保するための任意文字列。リクエストを発行する側が // 生成してリクエストに付与する。冪等性が必要なメソッドはこの // メッセージをフィールドに持つこと。 message Key { string value = 1; }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.meta.v1; import "yamori/meta/v1/ping_request.proto"; import "yamori/meta/v1/ping_response.proto"; option go_package = "pocka.jp/x/yamori/proto/go/meta/v1"; service MetaService { rpc Ping(PingRequest) returns (PingResponse); }
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.meta.v1; option go_package = "pocka.jp/x/yamori/proto/go/meta/v1"; message PingRequest {}
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.meta.v1; option go_package = "pocka.jp/x/yamori/proto/go/meta/v1"; message PingResponse {}
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.paid_leave_provision.v1; import "yamori/paid_leave_provision/v1/paid_leave_provision_table_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/paid_leave_provision/v1"; message ListSystemProvisionTablesRequest { // ok.tables の各要素にかけるマスク。 PaidLeaveProvisionTableReadMask read_mask = 1; }
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.paid_leave_provision.v1; import "yamori/error/v1/system_error.proto"; import "yamori/paid_leave_provision/v1/paid_leave_provision_table.proto"; option go_package = "pocka.jp/x/yamori/proto/go/paid_leave_provision/v1"; message ListSystemProvisionTablesResponse { message OK { repeated PaidLeaveProvisionTable tables = 1; } oneof result { OK ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; } }
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.paid_leave_provision.v1; import "yamori/paid_leave_provision/v1/list_system_provision_tables_request.proto"; import "yamori/paid_leave_provision/v1/list_system_provision_tables_response.proto"; option go_package = "pocka.jp/x/yamori/proto/go/paid_leave_provision/v1"; service PaidLeaveProvisionService { // システムによって定義されている、法令で定められている最低限の日数の付与テーブルの // 一覧を取得する。 rpc ListSystemProvisionTables(ListSystemProvisionTablesRequest) returns (ListSystemProvisionTablesResponse); }
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.paid_leave_provision.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/paid_leave_provision/v1/paid_leave_provision_table_id.proto"; import "yamori/paid_leave_provision/v1/paid_leave_provision_table_revision.proto"; option go_package = "pocka.jp/x/yamori/proto/go/paid_leave_provision/v1"; message PaidLeaveProvisionTable { PaidLeaveProvisionTableID id = 1; // このテーブルの表示名。 string display_name = 2; // 現在の内容。「現在」はコンテキストにより異なる。 PaidLeaveProvisionTableRevision current_revision = 3; // 法改正や就労規則の変更等で付与日数が変わる可能性があるため、内容は // 対象日によって複数持つことができる。このフィールドはその版の一覧。 repeated PaidLeaveProvisionTableRevision revisions = 4; // このテーブルを編集するためのキー。 yamori.capability.v1.CapabilityKey update_key = 5 [deprecated = true]; // 基となるテーブル。付与日数は基となるテーブルのものより少なくすること // はできない。複雑性の都合上 `base.base` は常に空となる。 PaidLeaveProvisionTable base = 6; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.paid_leave_provision.v1; option go_package = "pocka.jp/x/yamori/proto/go/paid_leave_provision/v1"; // 年次有給休暇の付与テーブルを識別する一意の ID 。 // 付与テーブルの ID は `pt-` というプリフィクスを持つ。 message PaidLeaveProvisionTableID { string value = 1; }
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.paid_leave_provision.v1; option go_package = "pocka.jp/x/yamori/proto/go/paid_leave_provision/v1"; // PaidLeaveProvisionTable を出力する際のフィールドマスク。 message PaidLeaveProvisionTableReadMask { // レスポンスに含める PaidLeaveProvisionTable のフィールド番号。 repeated int32 fields = 1; // `base` フィールドにかけるマスク。 PaidLeaveProvisionTableReadMask base_mask = 2; }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.paid_leave_provision.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/paid_leave_provision/v1"; message PaidLeaveProvisionTableRevision { // この版を参照する開始日。最初の版は空となる。 yamori.type.v1.Date start_at = 1; // 初回の付与時に付与する年次有給休暇の日数。 int32 first_provision_amount_days = 2; // 初回以降の付与日数。最初の要素が初回の次となり、最終要素以降の回は // 最終要素の日数と同様となる。 repeated int32 subsequent_provision_amount_days = 3; }
-
-
packages/proto/yamori/type/v1/date.proto (deleted)
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.type.v1; option go_package = "pocka.jp/x/yamori/proto/go/type/v1"; message Date { // 1~9999, 0 (未指定) の場合は年を指定しない月日。 uint32 year = 1; // 1~12, 0 (未指定) の場合は月を指定しない日。 uint32 month = 2; // 1~31 uint32 day = 3; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 休日。なお、ここでいう「休日」とは労働基準法における定義であり、 // "国民の祝日に関する法律" で定義されている「休日」とはなんら // 関係がない。 "day off" は包括的な "休み" だが、他にいい表現が // なかったためシステム上の単語として採用している。 message DayOff {}
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message DayOffWriteInput {}
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/record_kind.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message HalvedRecord { // 前半 (午前) の記録。 RecordKind am = 1; // 後半 (午後) の記録。 RecordKind pm = 2; }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/record_kind_write_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message HalvedRecordWriteInput { // 前半 (午前) の記録。 RecordKindWriteInput am = 1; // 後半 (午後) の記録。 RecordKindWriteInput pm = 2; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 時間単位年次有給休暇。 message HourlyPaidLeave { // この有給休暇が付与された年月日。 yamori.type.v1.Date provided_at = 1; // 利用した時間。 (1~24) uint32 hours = 2; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message HourlyPaidLeaveWriteInput { // 利用した時間。 (1~24) uint32 hours = 1; // 利用する年次有給休暇が付与された年月日。 // 未指定の場合は最も消滅日が近いものを利用する。 yamori.type.v1.Date provided_at = 2; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 一日未満、もしくは半日未満の時間単位で法定・特別休暇を取得した。 message HourlyWorkspaceDefinedLeave { // 対象となる法定・特別休暇。 Leave leave = 1; // 利用した時間。 (1~24) uint32 hours = 2; }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/leave_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message HourlyWorkspaceDefinedLeaveWriteInput { // 対象となる法定・特別休暇の ID 。 LeaveID leave_id = 1; // 利用した時間。 (1~24) uint32 hours = 2; }
-
-
-
@@ -1,50 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/work_record/v1/leave_id.proto"; import "yamori/work_record/v1/leave_revision.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 休暇や休業。英語としては果てしなく微妙だが、法令の英語表記及び // 一般的な英語の言い回しで「休日」と「休暇 (休業) 」を区別しないため // 労働基準法で用いられている "paid leave" から "leave" だけを抜き // 出して利用している。通常の会話だと "parental leave" のように前に // 理由をつけることが殆どのため、 "leave" 単体だと (特に会社の // コンテキストだと) 非常に伝わりづらい。 message Leave { LeaveID id = 1; // 有給休暇の出勤率計算を行う際に、この休暇・休業を行った日を出勤した // としてみなすかどうか。 // `current_revision.snapshot.is_worker_deemed_to_be_worked` を利用 // すること。このフィールドは削除予定。 bool is_worker_deemed_to_be_worked = 2 [deprecated = true]; string display_name = 3; // カレンダーのようなスペースの限られた場所で表示する省略名。 // 未設定の場合は `display_name` の先頭 2 文字となる。 string abbreviation_name = 8; // この休暇・休業のフィールド・属性を更新するためのキー。 yamori.capability.v1.CapabilityKey update_key = 4 [deprecated = true]; // この休暇・休業の定義を削除するためのキー。 yamori.capability.v1.CapabilityKey deletion_key = 7 [deprecated = true]; // 現在のバージョンを含む全てのバージョン。 // 適用開始日昇順でソートされており、未来のバージョンも含まれる。 repeated LeaveRevision revisions = 5; // 現在のバージョン。 // この休暇や休業が年月日に紐づいたものである場合はその日時点の // バージョン、マスタデータの場合はサーバ側の現在日もしくは // リクエストで指定された日付時点でのバージョンとなる。 LeaveRevision current_revision = 6; }
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/leave_revision_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 休暇や休業を作成する際のペイロード。 message LeaveCreateInput { // 有給休暇の出勤率計算を行う際に、この休暇・休業を行った日を出勤した // としてみなすかどうか。 // この値は無視されるため `revisions.snapshot` を利用すること。 bool is_worker_deemed_to_be_worked = 1 [deprecated = true]; // [必須] 表示名。 string display_name = 2; // カレンダーのようなスペースの限られた場所で表示する省略名。 // 未設定の場合は `display_name` の先頭 2 文字となる。 string abbreviation_name = 4; // [必須] 作成するバージョン、1つ以上。 repeated LeaveRevisionInput revisions = 3; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 休暇や休業を識別するための ID 。 // `lv-` というプリフィクスを持つ。 message LeaveID { string value = 1; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // Leave を出力する際のフィールドマスク。 message LeaveReadMask { // 出力に含める Leave のフィールド番号。 repeated int32 fields = 1; }
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; import "yamori/work_record/v1/leave_revision_id.proto"; import "yamori/work_record/v1/leave_snapshot.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 休暇や休業の定義は常に一定とは限らない。運用中にマスタデータをそのまま変更して // しまうと過去のデータにも影響を与えてしまうため、バージョン管理を行う。 // このメッセージは各バージョンのスナップショット。 // バージョン管理する必要のあるデータだけ含んでいる。 message LeaveRevision { LeaveRevisionID revision_id = 1; // このバージョンを適用する開始日。 yamori.type.v1.Date start_at = 2; // このバージョンでの状態、データ。 LeaveSnapshot snapshot = 3; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 休暇・休業の各バージョンを識別するための ID 。 // `lr-` というプリフィクスを持つ。 message LeaveRevisionID { string value = 1; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; import "yamori/work_record/v1/leave_snapshot.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message LeaveRevisionInput { // このバージョンを適用する開始日。 yamori.type.v1.Date start_at = 1; // このバージョンでの状態、データ。 LeaveSnapshot snapshot = 2; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message LeaveSnapshot { // 有給休暇の出勤率計算を行う際に、この休暇・休業を行った日を出勤した // としてみなすかどうか。 bool is_worker_deemed_to_be_worked = 1; }
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 年次有給休暇。 message PaidLeave { // この有給休暇が付与された年月日。 yamori.type.v1.Date provided_at = 1; }
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message PaidLeaveWriteInput { // 利用する年次有給休暇が付与された年月日。 // 未指定の場合は最も消滅日が近いものを利用する。 yamori.type.v1.Date provided_at = 1; }
-
-
-
@@ -1,35 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/day_off.proto"; import "yamori/work_record/v1/leave.proto"; import "yamori/work_record/v1/paid_leave.proto"; import "yamori/work_record/v1/skipped.proto"; import "yamori/work_record/v1/worked.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 勤怠の記録。 message RecordKind { oneof kind { // 出勤した。 Worked worked = 1; // 欠勤した。 Skipped skipped = 2; // 休日 (公休) 。 DayOff day_off = 3; // 年次有給休暇を利用した。 PaidLeave paid_leave = 4; // 法定休暇・休業や特別休暇といったワークスペースで定義されている // 休暇・休業を利用した。 Leave workspace_defined_leave = 5; } }
-
-
-
@@ -1,34 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/day_off_write_input.proto"; import "yamori/work_record/v1/leave_id.proto"; import "yamori/work_record/v1/paid_leave_write_input.proto"; import "yamori/work_record/v1/skipped_write_input.proto"; import "yamori/work_record/v1/worked_write_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message RecordKindWriteInput { oneof kind { // 出勤した。 WorkedWriteInput worked = 1; // 欠勤した。 SkippedWriteInput skipped = 2; // 休日 (公休) 。 DayOffWriteInput day_off = 3; // 年次有給休暇を利用した。 PaidLeaveWriteInput paid_leave = 4; // 法定休暇・休業や特別休暇といったワークスペースで定義されている // 休暇・休業を利用した際の ID 。 LeaveID workspace_defined_leave_id = 5; } }
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/hourly_paid_leave.proto"; import "yamori/work_record/v1/hourly_workspace_defined_leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 欠勤したことを表すレコード。 message Skipped { // 利用した時間単位年次有給休暇。 // 年次有給休暇付与の出勤率算定時に欠勤扱いを避けるために利用する // 可能性が排除できないため想定している。 HourlyPaidLeave hourly_paid_leave = 1; // 利用した一日・半日未満の法定・特別休暇の一覧。 // 年次有給休暇付与の出勤率算定時に欠勤扱いを避けるために利用する // 可能性が排除できないため想定している。 repeated HourlyWorkspaceDefinedLeave hourly_workspace_defined_leaves = 2; }
-
-
-
@@ -1,23 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/hourly_paid_leave_write_input.proto"; import "yamori/work_record/v1/hourly_workspace_defined_leave_write_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message SkippedWriteInput { // 利用した時間単位年次有給休暇。 // 年次有給休暇付与の出勤率算定時に欠勤扱いを避けるために利用する // 可能性が排除できないため想定している。 HourlyPaidLeaveWriteInput hourly_paid_leave = 1; // 利用した一日・半日未満の法定・特別休暇の一覧。 // 年次有給休暇付与の出勤率算定時に欠勤扱いを避けるために利用する // 可能性が排除できないため想定している。 repeated HourlyWorkspaceDefinedLeaveWriteInput hourly_workspace_defined_leave = 2; }
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/hourly_paid_leave.proto"; import "yamori/work_record/v1/paid_leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 一日の所定労働時間を下回る休暇。 message TimeOff { oneof kind { // 年次有給休暇を半日に分けて取得する場合の一単位。 // 法令上、時間単位年休制度を利用せずに年次有給休暇を分割することは // 認められていないが、時間単位年休制度が設けられる前から黙認され // 続けている運用。厚生労働省が公式に容認してしまっているため広く // 利用されている。 PaidLeave halved_paid_leave = 1; // 時間単位年休。 HourlyPaidLeave hourly_paid_leave = 2; } }
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/hourly_paid_leave_write_input.proto"; import "yamori/work_record/v1/paid_leave_write_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // フィールド詳細に関する説明は TimeOff を参照。 message TimeOffWriteInput { oneof kind { PaidLeaveWriteInput halved_paid_leave = 1; HourlyPaidLeaveWriteInput hourly_paid_leave = 2; } }
-
-
-
@@ -1,52 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; import "yamori/work_record/v1/day_off.proto"; import "yamori/work_record/v1/halved_record.proto"; import "yamori/work_record/v1/leave.proto"; import "yamori/work_record/v1/paid_leave.proto"; import "yamori/work_record/v1/record_kind.proto"; import "yamori/work_record/v1/working_day.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message WorkRecord { reserved 5, 6; yamori.type.v1.Date date = 1; // その日の労働状況。 // [DEPRECATED] kind を利用すること。 oneof record { // 労働が行われた、もしくは予定されていた。 WorkingDay working_day = 2 [deprecated = true]; // 休日。 DayOff day_off = 3 [deprecated = true]; // 年次有給休暇を利用した。 PaidLeave paid_leave = 4 [deprecated = true]; // 法定休暇・休業や特別休暇といったワークスペースで定義されている // 休暇・休業を利用した。 Leave workspace_defined_leave = 7 [deprecated = true]; } oneof kind { // 一日全体の記録。 RecordKind day_whole = 8; // 勤務を前半 (午前) と後半 (午後) に分けて記録している。 // e.g. 半有、半出勤 HalvedRecord day_halved = 9; } // ユーザが残したメモ、記載事項。 // システムがこのフィールドを自動的にパースすることはない。 string note = 12; }
-
-
-
@@ -1,44 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; import "yamori/work_record/v1/day_off_write_input.proto"; import "yamori/work_record/v1/halved_record_write_input.proto"; import "yamori/work_record/v1/leave_id.proto"; import "yamori/work_record/v1/paid_leave_write_input.proto"; import "yamori/work_record/v1/record_kind_write_input.proto"; import "yamori/work_record/v1/working_day_write_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message WorkRecordBatchWriteInput { reserved 5, 6; // 更新対象の日付。複数ある場合は対象の日付全てに同じ更新を行う。 repeated yamori.type.v1.Date dates = 1; oneof record { WorkingDayWriteInput working_day = 2 [deprecated = true]; DayOffWriteInput day_off = 3 [deprecated = true]; PaidLeaveWriteInput paid_leave = 4 [deprecated = true]; LeaveID workspace_defined_leave_id = 7 [deprecated = true]; } oneof kind { // 一日全体の記録。 RecordKindWriteInput day_whole = 8; // 勤務を前半 (午前) と後半 (午後) に分けて記録している。 HalvedRecordWriteInput day_halved = 9; } // ユーザが残したメモ、記載事項。 string note = 12; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message WorkRecordBatchWriteMask { // 更新する WorkRecordBatchWriteInput のフィールド番号。 // `dates` は処理に必ず必要なため未指定でも読まれる。 repeated int32 fields = 1; }
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message WorkRecordFilter { // 取得範囲開始日。結果にはこの日が含まれる。 // 未指定の場合はサーバ時間でのリクエスト処理日となる。 yamori.type.v1.Date since = 1; // 取得範囲終了日。結果にはこの日が含まれる。 // 未指定の場合はサーバ時間でのリクエスト処理日となる。 yamori.type.v1.Date until = 2; }
-
-
-
@@ -1,21 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/leave_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // yamori.work_record.v1.WorkRecord を出力する際のマスク。 message WorkRecordReadMask { reserved 5, 6; // 出力に含めるフィールド番号。 repeated int32 fields = 1; // workspace_defined_leave にかけるマスク。 LeaveReadMask workspace_defined_leave_mask = 2; }
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/hourly_paid_leave.proto"; import "yamori/work_record/v1/hourly_workspace_defined_leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; // 出勤したことを表すレコード。 message Worked { // 利用した時間単位年次有給休暇。 HourlyPaidLeave hourly_paid_leave = 1; // 利用した一日・半日未満の法定・特別休暇の一覧。 repeated HourlyWorkspaceDefinedLeave hourly_workspace_defined_leaves = 2; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/hourly_paid_leave_write_input.proto"; import "yamori/work_record/v1/hourly_workspace_defined_leave_write_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message WorkedWriteInput { // 利用した時間単位年次有給休暇。 HourlyPaidLeaveWriteInput hourly_paid_leave = 1; // 利用した一日・半日未満の法定・特別休暇の一覧。 repeated HourlyWorkspaceDefinedLeaveWriteInput hourly_workspace_defined_leave = 2; }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/time_off.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message WorkingDay { // 労働者が出勤したかどうか。 bool has_worker_worked = 1; // 利用した1日未満の休暇。 repeated TimeOff time_offs = 2; }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v1; import "yamori/work_record/v1/time_off_write_input.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v1"; message WorkingDayWriteInput { // 労働者が出勤したかどうか。 bool has_worker_worked = 1; // 利用した1日未満の休暇。 repeated TimeOffWriteInput time_offs = 2; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 休日。なお、ここでいう「休日」とは労働基準法における定義であり、 // "国民の祝日に関する法律" で定義されている「休日」とはなんら // 関係がない。 "day off" は包括的な "休み" だが、他にいい表現が // なかったためシステム上の単語として採用している。 message DayOff {}
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/work_record/v2/record_kind.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; message HalvedRecord { // 前半 (午前) の記録。 RecordKind am = 1; // 後半 (午後) の記録。 RecordKind pm = 2; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 時間単位年次有給休暇。 message HourlyPaidLeave { // この有給休暇が付与された年月日。 yamori.type.v1.Date provided_at = 1; // 利用した時間。 (1~24) uint32 hours = 2; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/work_record/v2/leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 一日未満、もしくは半日未満の時間単位で法定・特別休暇を取得した。 message HourlyWorkspaceDefinedLeave { // 対象となる法定・特別休暇。 Leave leave = 1; // 利用した時間。 (1~24) uint32 hours = 2; }
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/work_record/v2/leave_id.proto"; import "yamori/work_record/v2/leave_revision.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 休暇や休業。英語としては果てしなく微妙だが、法令の英語表記及び // 一般的な英語の言い回しで「休日」と「休暇 (休業) 」を区別しないため // 労働基準法で用いられている "paid leave" から "leave" だけを抜き // 出して利用している。通常の会話だと "parental leave" のように前に // 理由をつけることが殆どのため、 "leave" 単体だと (特に会社の // コンテキストだと) 非常に伝わりづらい。 message Leave { LeaveID id = 1; string display_name = 2; // カレンダーのようなスペースの限られた場所で表示する省略名。 // 未設定の場合は `display_name` の先頭 2 文字となる。 string abbreviation_name = 3; // 現在のバージョンを含む全てのバージョン。 // 適用開始日昇順でソートされており、未来のバージョンも含まれる。 repeated LeaveRevision revisions = 4; // 現在のバージョン。 // この休暇や休業が年月日に紐づいたものである場合はその日時点の // バージョン、マスタデータの場合はサーバ側の現在日もしくは // リクエストで指定された日付時点でのバージョンとなる。 LeaveRevision current_revision = 5; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 休暇や休業を識別するための ID 。 // `lv-` というプリフィクスを持つ。 message LeaveID { string value = 1; }
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/type/v1/date.proto"; import "yamori/work_record/v2/leave_revision_id.proto"; import "yamori/work_record/v2/leave_snapshot.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 休暇や休業の定義は常に一定とは限らない。運用中にマスタデータをそのまま変更して // しまうと過去のデータにも影響を与えてしまうため、バージョン管理を行う。 // このメッセージは各バージョンのスナップショット。 // バージョン管理する必要のあるデータだけ含んでいる。 message LeaveRevision { LeaveRevisionID revision_id = 1; // このバージョンを適用する開始日。 yamori.type.v1.Date start_at = 2; // このバージョンでの状態、データ。 LeaveSnapshot snapshot = 3; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 休暇・休業の各バージョンを識別するための ID 。 // `lr-` というプリフィクスを持つ。 message LeaveRevisionID { string value = 1; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; message LeaveSnapshot { // 有給休暇の出勤率計算を行う際に、この休暇・休業を行った日を出勤した // としてみなすかどうか。 bool is_worker_deemed_to_be_worked = 1; }
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 年次有給休暇。 message PaidLeave { // この有給休暇が付与された年月日。 yamori.type.v1.Date provided_at = 1; }
-
-
-
@@ -1,35 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/work_record/v2/day_off.proto"; import "yamori/work_record/v2/leave.proto"; import "yamori/work_record/v2/paid_leave.proto"; import "yamori/work_record/v2/skipped.proto"; import "yamori/work_record/v2/worked.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 勤怠の記録。 message RecordKind { oneof kind { // 出勤した。 Worked worked = 1; // 欠勤した。 Skipped skipped = 2; // 休日 (公休) 。 DayOff day_off = 3; // 年次有給休暇を利用した。 PaidLeave paid_leave = 4; // 法定休暇・休業や特別休暇といったワークスペースで定義されている // 休暇・休業を利用した。 Leave workspace_defined_leave = 5; } }
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/work_record/v2/hourly_paid_leave.proto"; import "yamori/work_record/v2/hourly_workspace_defined_leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 欠勤したことを表すレコード。 message Skipped { // 利用した時間単位年次有給休暇。 // 年次有給休暇付与の出勤率算定時に欠勤扱いを避けるために利用する // 可能性が排除できないため想定している。 HourlyPaidLeave hourly_paid_leave = 1; // 利用した一日・半日未満の法定・特別休暇の一覧。 // 年次有給休暇付与の出勤率算定時に欠勤扱いを避けるために利用する // 可能性が排除できないため想定している。 repeated HourlyWorkspaceDefinedLeave hourly_workspace_defined_leaves = 2; }
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/type/v1/date.proto"; import "yamori/work_record/v2/halved_record.proto"; import "yamori/work_record/v2/record_kind.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; message WorkRecord { yamori.type.v1.Date date = 1; // ユーザが残したメモ、記載事項。 // システムがこのフィールドを自動的にパースすることはない。 string note = 2; oneof kind { // 一日全体の記録。 RecordKind day_whole = 3; // 勤務を前半 (午前) と後半 (午後) に分けて記録している。 // e.g. 半有、半出勤 HalvedRecord day_halved = 4; } }
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.work_record.v2; import "yamori/work_record/v2/hourly_paid_leave.proto"; import "yamori/work_record/v2/hourly_workspace_defined_leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/work_record/v2;work_record"; // 出勤したことを表すレコード。 message Worked { // 利用した時間単位年次有給休暇。 HourlyPaidLeave hourly_paid_leave = 1; // 利用した一日・半日未満の法定・特別休暇の一覧。 repeated HourlyWorkspaceDefinedLeave hourly_workspace_defined_leaves = 2; }
-
-
-
@@ -1,41 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/idempotency/v1/key.proto"; import "yamori/paid_leave_provision/v1/paid_leave_provision_table_id.proto"; import "yamori/type/v1/date.proto"; import "yamori/worker/v1/custom_field.proto"; import "yamori/worker/v1/worker_read_mask.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message CreateRequest { yamori.idempotency.v1.Key idempotency_key = 1; // [必須] 労働者を作成するワークスペースの ID 。 yamori.workspace.v1.WorkspaceID workspace_id = 2; // [必須] 労働者の表示名。 string display_name = 3; // カスタムフィールドの一覧。 repeated CustomField custom_fields = 8; // 初回の年次有給休暇の付与日。 yamori.type.v1.Date first_paid_leave_provision_at = 6; // 割り当てる年次有給休暇の付与テーブルの ID 。 yamori.paid_leave_provision.v1.PaidLeaveProvisionTableID paid_leave_provision_table_id = 7; // 労働者の登録を行うための対象ワークスペースのキー。 yamori.capability.v1.CapabilityKey worker_add_key = 4 [deprecated = true]; // ok.worker にかけるフィールドマスク。 WorkerReadMask read_mask = 5; }
-
-
-
@@ -1,39 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/worker/v1/worker.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message CreateResponse { message Result { // 作成された労働者。 Worker worker = 1; } reserved 2; oneof result { Result ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 3; // 必須フィールドに値が入っていないため作成されなかった。 yamori.error.v1.MissingFieldError missing_field = 4; // 権限がない、もしくはキーが不正。 yamori.error.v1.CapabilityError capability_error = 5; // 指定されたワークスペースが存在しない、アクセスできない。 yamori.error.v1.NotFound not_found = 6; } }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/workspace/v1/custom_field_definition.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message CustomField { // [必須] ワークスペースで設定されている、このカスタムフィールドの定義。 yamori.workspace.v1.CustomFieldDefinition definition = 1; // カスタムフィールドの値。 string value = 2; }
-
-
-
@@ -1,31 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/work_record/v1/work_record_filter.proto"; import "yamori/worker/v1/paid_leave_provision_filter.proto"; import "yamori/worker/v1/worker_id.proto"; import "yamori/worker/v1/worker_read_mask.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message GetRequest { // [必須] 対象労働者の所属するワークスペースの ID 。 yamori.workspace.v1.WorkspaceID workspace_id = 1; // [必須] 対象労働者の ID 。 WorkerID worker_id = 2; // ok にかけるフィールドマスク。 WorkerReadMask read_mask = 3; // ok.work_records の取得対象。 yamori.work_record.v1.WorkRecordFilter work_record_filter = 4; // ok.paid_leave_provisions の取得対象。 PaidLeaveProvisionFilter paid_leave_provision_filter = 5; }
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/worker/v1/worker.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message GetResponse { oneof result { Worker ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 指定されたワークスペースもしくは労働者が存在しない。 yamori.error.v1.NotFound not_found = 3; // 必須フィールドに値が入っていない。 yamori.error.v1.MissingFieldError missing_field = 4; } }
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/work_record/v1/work_record_filter.proto"; import "yamori/worker/v1/paid_leave_provision_filter.proto"; import "yamori/worker/v1/worker_read_mask.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message ListRequest { // [必須] 対象ワークスペースの ID 。 yamori.workspace.v1.WorkspaceID workspace_id = 1; // ok.workers の各要素にかけるフィールドマスク。 WorkerReadMask read_mask = 2; // ok.workers.work_records の取得対象。 yamori.work_record.v1.WorkRecordFilter work_record_filter = 3; // ok.workers.paid_leave_provisions の取得対象。 PaidLeaveProvisionFilter paid_leave_provision_filter = 4; }
-
-
-
@@ -1,39 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/worker/v1/worker.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message ListResponse { message Result { // 一覧の属するワークスペース。 yamori.workspace.v1.WorkspaceID workspace_id = 1; // ワークスペース内に存在するアクセス可能な労働者の一覧。 repeated Worker workers = 2; } reserved 2; oneof result { Result ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 3; // 必須フィールドに値が入っていない。 yamori.error.v1.MissingFieldError missing_field = 4; // 指定されたワークスペースが見つからない、アクセスできない。 yamori.error.v1.NotFound not_found = 5; } }
-
-
-
@@ -1,43 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "google/protobuf/timestamp.proto"; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message PaidLeaveProvision { // 付与日。 yamori.type.v1.Date provided_at = 1; // 失効日。 yamori.type.v1.Date expires_at = 2; // 付与された年次有給休暇の日数。 int32 amount_days = 3; // 付与された年次有給休暇の残日数。 int32 remaining_days = 4; // 年次有給休暇の半日取得を行った際のもう半日分が余っているかどうか。 // 半日分も含めた残日数は以下のようにして計算できる。 // ``` // a = 0.5 if is_halved_day_remaining // 0 otherwise // x = remaining_days + a // ``` bool is_halved_day_remaining = 5; // 付与が行われた日時。 google.protobuf.Timestamp created_at = 6; // 内容の最終更新日時。 google.protobuf.Timestamp updated_at = 7; // この付与に関するメモ。 string note = 8; }
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message PaidLeaveProvisionFilter { // 付与日がこの日付と同じかそれ以降の付与に限定する。 yamori.type.v1.Date provided_at_since = 1; // 付与日がこの日付と同じかそれ以前の付与に限定する。 yamori.type.v1.Date provided_at_until = 2; // 失効日がこの日付と同じかそれ以降の付与に限定する。 yamori.type.v1.Date expires_at_since = 3; // 失効日がこの日付と同じかそれ以前の付与に限定する。 yamori.type.v1.Date expires_at_until = 4; }
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message PaidLeaveProvisionInput { // [必須] 付与日。キーとなるため同日の付与履歴がある場合は上書きをする。 yamori.type.v1.Date provided_at = 1; // 失効日。未指定の場合は provided_at の 2 年後。 yamori.type.v1.Date expires_at = 2; // 付与する年次有給休暇の日数。 // 負数は 0 として扱われるが、将来的に変わる可能性もあるため指定しないこと。 // 0 (未指定) の場合は付与しなかったことになり、既存の付与履歴を削除する。 int32 amount_days = 3; // この付与に関するメモ。 string note = 4; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message PaidLeaveProvisionInputMask { // 書き込む `PaidLeaveProvisionInput` のフィールド番号。 // `provided_at` は必須のため未指定でも読まれる。 repeated int32 fields = 1; }
-
-
-
@@ -1,13 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message PaidLeaveProvisionReadMask { // 出力に含める yamori.worker.v1.PaidLeaveProvision のフィールド番号。 repeated int32 fields = 1; }
-
-
-
@@ -1,35 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/worker/v1/paid_leave_provision_input.proto"; import "yamori/worker/v1/paid_leave_provision_input_mask.proto"; import "yamori/worker/v1/paid_leave_provision_read_mask.proto"; import "yamori/worker/v1/worker_id.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message ProvidePaidLeaveRequest { // [必須] 対象労働者が所属するワークスペースの ID 。 yamori.workspace.v1.WorkspaceID workspace_id = 1; // [必須] 対象労働者の ID 。 WorkerID worker_id = 2; // 対象労働者に年次有給休暇を付与するためのキー。 yamori.capability.v1.CapabilityKey provide_paid_leave_key = 3 [deprecated = true]; // [必須] 付与する年次有給休暇。 PaidLeaveProvisionInput paid_leave = 4; // `paid_leave` に対してかけるフィールドマスク。 PaidLeaveProvisionInputMask write_mask = 5; // ProvidePaidLeaveResponse.ok にかけるマスク。 PaidLeaveProvisionReadMask read_mask = 6; }
-
-
-
@@ -1,36 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/worker/v1/paid_leave_provision.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message ProvidePaidLeaveResponse { message OK { PaidLeaveProvision paid_leave_provision = 1; } oneof result { OK ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため作成されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 権限がない、もしくはキーが不正。 yamori.error.v1.CapabilityError capability_error = 4; // 指定されたワークスペースもしくは労働者が存在しない、アクセスできない。 yamori.error.v1.NotFound not_found = 5; } }
-
-
-
@@ -1,45 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/paid_leave_provision/v1/paid_leave_provision_table.proto"; import "yamori/type/v1/date.proto"; import "yamori/work_record/v1/work_record.proto"; import "yamori/worker/v1/custom_field.proto"; import "yamori/worker/v1/paid_leave_provision.proto"; import "yamori/worker/v1/worker_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; // 被雇用の労働者を表す。 message Worker { WorkerID id = 1; // 労働者の表示名。 string display_name = 2; // 勤怠記録。 repeated yamori.work_record.v1.WorkRecord work_records = 3; // 年次有給休暇の付与記録。 repeated PaidLeaveProvision paid_leave_provisions = 5; // 勤怠記録を更新するためのキー。 yamori.capability.v1.CapabilityKey write_work_record_key = 4 [deprecated = true]; // 年次有給休暇をこの労働者に付与するためのキー。 yamori.capability.v1.CapabilityKey provide_paid_leave_key = 6 [deprecated = true]; // 年次有給休暇の付与テーブル。 yamori.paid_leave_provision.v1.PaidLeaveProvisionTable paid_leave_provision_table = 7; // 初回の年次有給休暇の付与日。 yamori.type.v1.Date first_paid_leave_provision_at = 8; // カスタムフィールドの一覧。 repeated CustomField custom_fields = 9; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; // システムによって割り振られたワークスペース内で一意の識別子。 // 労働者の ID は `wr-` というプリフィクスを持つ。 message WorkerID { string value = 1; }
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/paid_leave_provision/v1/paid_leave_provision_table_read_mask.proto"; import "yamori/work_record/v1/work_record_read_mask.proto"; import "yamori/worker/v1/paid_leave_provision_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; // Worker を出力する際のフィールドマスク。 message WorkerReadMask { // 出力に含める yamori.worker.v1.Worker のフィールド番号。 repeated int32 fields = 1; // work_records の各要素にかけるマスク。 yamori.work_record.v1.WorkRecordReadMask work_records_mask = 2; // paid_leave_provisions の各要素にかけるマスク。 PaidLeaveProvisionReadMask paid_leave_provisions_mask = 3; // paid_leave_provision_table にかけるマスク。 yamori.paid_leave_provision.v1.PaidLeaveProvisionTableReadMask paid_leave_provision_table_mask = 4; }
-
-
-
@@ -1,36 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/worker/v1/create_request.proto"; import "yamori/worker/v1/create_response.proto"; import "yamori/worker/v1/get_request.proto"; import "yamori/worker/v1/get_response.proto"; import "yamori/worker/v1/list_request.proto"; import "yamori/worker/v1/list_response.proto"; import "yamori/worker/v1/provide_paid_leave_request.proto"; import "yamori/worker/v1/provide_paid_leave_response.proto"; import "yamori/worker/v1/write_work_record_request.proto"; import "yamori/worker/v1/write_work_record_response.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; service WorkerService { // ID を指定して労働者を取得する。 rpc Get(GetRequest) returns (GetResponse); // ワークスペース内のアクセス可能な労働者の一覧を返す。 rpc List(ListRequest) returns (ListResponse); // ワークスペース内に労働者を新規登録する。 rpc Create(CreateRequest) returns (CreateResponse); // 労働者の勤怠記録を更新する。 rpc WriteWorkRecord(WriteWorkRecordRequest) returns (WriteWorkRecordResponse); // 労働者に年次有給休暇を付与する。 rpc ProvidePaidLeave(ProvidePaidLeaveRequest) returns (ProvidePaidLeaveResponse); }
-
-
-
@@ -1,35 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/work_record/v1/work_record_batch_write_input.proto"; import "yamori/work_record/v1/work_record_batch_write_mask.proto"; import "yamori/work_record/v1/work_record_read_mask.proto"; import "yamori/worker/v1/worker_id.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message WriteWorkRecordRequest { // [必須] 対象労働者が所属するワークスペースの ID 。 yamori.workspace.v1.WorkspaceID workspace_id = 1; // [必須] 対象労働者の ID 。 WorkerID worker_id = 2; // [必須] 対象労働者の勤怠記録を更新するためのキー。 yamori.capability.v1.CapabilityKey write_work_record_key = 3 [deprecated = true]; // [必須] 書き込む勤怠記録。 yamori.work_record.v1.WorkRecordBatchWriteInput work_record = 4; // 更新するフィールドマスク。未指定の場合は全てのフィールドを上書き更新する。 yamori.work_record.v1.WorkRecordBatchWriteMask write_mask = 5; // WriteWorkRecordResponse.ok.work_records の各要素にかけるマスク。 yamori.work_record.v1.WorkRecordReadMask read_mask = 6; }
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.worker.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/work_record/v1/work_record.proto"; option go_package = "pocka.jp/x/yamori/proto/go/worker/v1"; message WriteWorkRecordResponse { message OK { // 更新後の勤怠記録。 repeated yamori.work_record.v1.WorkRecord work_records = 1; } oneof result { OK ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため作成されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 権限がない、もしくはキーが不正。 yamori.error.v1.CapabilityError capability_error = 4; // 指定されたワークスペースもしくは労働者が存在しない、アクセスできない。 yamori.error.v1.NotFound not_found = 5; } }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message Abbreviations { // 休日の省略表示。 string dayoff = 1; // 出勤の省略表示。 string worked = 2; // 欠勤の省略表示。 string skip_work = 3; // 年次有給休暇 (取得) の省略表示。 string paid_leave = 4; }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/workspace/v1/abbreviations.proto"; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message ConfigureSingletonRequest { // ワークスペースの表示名。 string display_name = 1; // ワークスペース内で用いる省略表記。 Abbreviations abbreviations = 2; // ConfigureSingletonResponse.ok に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 3; }
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/no_storage_space.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message ConfigureSingletonResponse { oneof result { // 作成されたワークスペース。 Workspace ok = 1; // 既に作成済み。 yamori.error.v1.NoStorageSpace already_configured = 2; // 必須フィールドに値が入っていないため保存されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 4; } }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message CreateCustomFieldDefinitionRequest { // [必須] 追加する対象のワークスペース。 WorkspaceID workspace_id = 1; // カスタムフィールドの定義の変更を行うためのキー。 yamori.capability.v1.CapabilityKey custom_field_definitions_write_key = 2 [deprecated = true]; // [必須] 表示名。 string display_name = 3; }
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/custom_field_definition.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message CreateCustomFieldDefinitionResponse { oneof result { // 作成されたカスタムフィールド。 CustomFieldDefinition ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため保存されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 対象のワークスペースが存在しない。 yamori.error.v1.NotFound not_found = 4; // 作成する権限がない。 yamori.error.v1.CapabilityError capability_error = 5; } }
-
-
-
@@ -1,30 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/idempotency/v1/key.proto"; import "yamori/work_record/v1/leave_create_input.proto"; import "yamori/work_record/v1/leave_read_mask.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message CreateLeaveDefinitionRequest { yamori.idempotency.v1.Key idempotency_key = 1; // CreateLeaveDefinitionResponse.ok に対してかけるマスク。 yamori.work_record.v1.LeaveReadMask read_mask = 2; // [必須] 登録する対象のワークスペース。 WorkspaceID workspace_id = 3; // [必須] 休暇休業定義の登録を行うためのキー。 yamori.capability.v1.CapabilityKey create_leave_definition_key = 4 [deprecated = true]; // [必須] 登録する内容。 yamori.work_record.v1.LeaveCreateInput leave_definition = 5; }
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/no_storage_space.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/work_record/v1/leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message CreateLeaveDefinitionResponse { oneof result { // 作成された休暇休業定義。 yamori.work_record.v1.Leave ok = 1; // 保存領域がないため保存できなかった。 yamori.error.v1.NoStorageSpace no_storage_space = 2; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 3; // 必須フィールドに値が入っていないため保存されなかった。 yamori.error.v1.MissingFieldError missing_field = 4; // 対象のワークスペースが存在しない。 yamori.error.v1.NotFound not_found = 5; // 作成する権限がない。 yamori.error.v1.CapabilityError capability_error = 6; } }
-
-
-
@@ -1,25 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/idempotency/v1/key.proto"; import "yamori/workspace/v1/custom_field_definition.proto"; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message CreateRequest { yamori.idempotency.v1.Key idempotency_key = 1; // [必須] ワークスペースの表示名。 string display_name = 2; // カスタムフィールドの一覧。 repeated CustomFieldDefinition custom_field_definitions = 4; // CreateResponse.ok.workspace に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 3; }
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/no_storage_space.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message CreateResponse { message Result { // 作成されたワークスペース。 Workspace workspace = 1; } oneof result { Result ok = 1; // 保存領域がないため保存できなかった。 yamori.error.v1.NoStorageSpace no_storage_space = 2; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 3; // 必須フィールドに値が入っていないため保存されなかった。 yamori.error.v1.MissingFieldError missing_field = 4; } }
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/workspace/v1/custom_field_definition_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message CustomFieldDefinition { CustomFieldDefinitionID id = 1; // 表示名。実際にユーザが目にするもの。 string display_name = 2; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; // システムによって割り振られたシステム内で一意の識別子。 // カスタムフィールド定義の ID は `cf-` というプリフィクスを持つ。 message CustomFieldDefinitionID { string value = 1; }
-
-
-
@@ -1,23 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/workspace/v1/custom_field_definition_id.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message DeleteCustomFieldDefinitionRequest { // [必須] 対象のカスタムフィールド定義が存在するワークスペース。 WorkspaceID workspace_id = 1; // カスタムフィールドの定義の変更を行うためのキー。 yamori.capability.v1.CapabilityKey custom_field_definitions_write_key = 2 [deprecated = true]; // 削除する対象の ID 。 CustomFieldDefinitionID custom_field_definition_id = 3; }
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/custom_field_definition.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message DeleteCustomFieldDefinitionResponse { oneof result { // 削除されたカスタムフィールド定義。 CustomFieldDefinition ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため更新されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 対象のワークスペース、もしくはカスタムフィールド定義が存在しない。 yamori.error.v1.NotFound not_found = 4; // 削除する権限がない。 yamori.error.v1.CapabilityError capability_error = 5; } }
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/work_record/v1/leave_id.proto"; import "yamori/work_record/v1/leave_read_mask.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message DeleteLeaveDefinitionRequest { // DeleteLeaveDefinitionResponse.ok.deleted に対してかけるマスク。 yamori.work_record.v1.LeaveReadMask read_mask = 1; // [必須] 対象の定義が存在するワークスペース。 WorkspaceID workspace_id = 2; // [必須] 対象の定義 ID 。 yamori.work_record.v1.LeaveID leave_definition_id = 3; // [必須] 対象の `deletion_key` フィールドに存在するキー。 yamori.capability.v1.CapabilityKey deletion_key = 4 [deprecated = true]; }
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/work_record/v1/leave.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message DeleteLeaveDefinitionResponse { message Result { // 削除された定義。 yamori.work_record.v1.Leave deleted = 1; } oneof result { Result ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため保存されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 対象のワークスペースもしくは定義が存在しない。 yamori.error.v1.NotFound not_found = 4; // 削除する権限がない。 yamori.error.v1.CapabilityError capability_error = 5; } }
-
-
-
@@ -1,23 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/workspace/v1/workspace_id.proto"; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message DeleteRequest { // [必須] 削除対象のワークスペースの ID 。 WorkspaceID id = 1; // [必須] 削除を行うためのキー。 yamori.capability.v1.CapabilityKey deletion_key = 2 [deprecated = true]; // DeleteResponse.ok.workspace に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 3; }
-
-
-
@@ -1,39 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message DeleteResponse { message Result { // 削除されたワークスペース。 Workspace workspace = 1; } oneof result { Result ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため削除されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 削除対象のワークスペースが見つからない。 // - ID がおかしい // - 既に削除された yamori.error.v1.NotFound not_found = 4; // 権限がない、もしくはキーが不正。 yamori.error.v1.CapabilityError capability_error = 5; } }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/workspace/v1/workspace_id.proto"; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message GetRequest { // 対象のワークスペースの ID 。 WorkspaceID workspace_id = 1; // GetResponse.ok に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 2; }
-
-
-
@@ -1,31 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message GetResponse { oneof result { // 対象のワークスペース。 Workspace ok = 1; // システムエラーが発生したため取得処理が中断された。 yamori.error.v1.SystemError system_error = 2; // 対象のワークスペースが見つからない。 // - ID がおかしい // - 既に削除された yamori.error.v1.NotFound not_found = 3; // 必須フィールドに値が入っていないため処理が中断された。 yamori.error.v1.MissingFieldError missing_field = 4; } }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message GetSingletonRequest { // GetSingletonResponse.ok に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 1; }
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message GetSingletonResponse { oneof result { Workspace ok = 1; // ワークスペースが未設定。 yamori.error.v1.NotFound not_found = 2; // システムエラーが発生したため取得処理が中断された。 yamori.error.v1.SystemError system_error = 3; } }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message ListRequest { // ok.workspaces の各要素に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 1; }
-
-
-
@@ -1,25 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message ListResponse { message Result { // アクセス可能なワークスペースの一覧。 repeated Workspace workspaces = 1; } oneof result { Result ok = 1; // システムエラーが発生したため一覧を取得できなかった。 yamori.error.v1.SystemError system_error = 2; } }
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message LogoutRequest {}
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/system_error.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message LogoutResponse { // 予期せぬシステムエラー。 // このフィールドが空の場合は正常にログアウトできたということになる。 yamori.error.v1.SystemError system_error = 1; }
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/workspace/v1/configure_singleton_request.proto"; import "yamori/workspace/v1/configure_singleton_response.proto"; import "yamori/workspace/v1/get_singleton_request.proto"; import "yamori/workspace/v1/get_singleton_response.proto"; import "yamori/workspace/v1/update_singleton_request.proto"; import "yamori/workspace/v1/update_singleton_response.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; // 一つのワークスペースのみを設定できるバックエンドにおけるワークスペースサービス。 service SingletonWorkspaceService { // 設定されているワークスペースを取得する。 rpc GetSingleton(GetSingletonRequest) returns (GetSingletonResponse); // 設定されているワークスペースを更新する。 rpc UpdateSingleton(UpdateSingletonRequest) returns (UpdateSingletonResponse); // ワークスペースを設定する。既に設定済みの場合はエラーとなる。 rpc ConfigureSingleton(ConfigureSingletonRequest) returns (ConfigureSingletonResponse); }
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/workspace/v1/custom_field_definition_id.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message UpdateCustomFieldDefinitionRequest { // [必須] 対象のカスタムフィールド定義が存在するワークスペース。 WorkspaceID workspace_id = 1; // カスタムフィールドの定義の変更を行うためのキー。 yamori.capability.v1.CapabilityKey custom_field_definitions_write_key = 2 [deprecated = true]; // 更新する対象の ID 。 CustomFieldDefinitionID custom_field_definition_id = 3; // 表示名。 string display_name = 4; }
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/custom_field_definition.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message UpdateCustomFieldDefinitionResponse { oneof result { // 更新後のカスタムフィールド。 CustomFieldDefinition ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため更新されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 対象のワークスペース、もしくはカスタムフィールド定義が存在しない。 yamori.error.v1.NotFound not_found = 4; // 更新する権限がない。 yamori.error.v1.CapabilityError capability_error = 5; } }
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "google/protobuf/field_mask.proto"; import "yamori/capability/v1/capability_key.proto"; import "yamori/workspace/v1/abbreviations.proto"; import "yamori/workspace/v1/workspace_id.proto"; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message UpdateRequest { // [必須] 更新対象のワークスペースの ID 。 WorkspaceID id = 1; // ワークスペースの表示名。 string display_name = 2; // ワークスペース内で用いる省略表記。 Abbreviations abbreviations = 6; // 更新するフィールドの一覧。 // 有効なパス: // - `display_name` // - `abbreviations.*` google.protobuf.FieldMask field_mask = 3; // [必須] ワークスペース自体の情報の更新を行うためのキー。 yamori.capability.v1.CapabilityKey update_key = 4 [deprecated = true]; // UpdateResponse.ok.workspace に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 5; }
-
-
-
@@ -1,39 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message UpdateResponse { message Result { // 更新後のワークスペース。 Workspace workspace = 1; } oneof result { Result ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 必須フィールドに値が入っていないため更新されなかった。 yamori.error.v1.MissingFieldError missing_field = 3; // 更新対象のワークスペースが見つからない。 // - ID がおかしい // - 既に削除された yamori.error.v1.NotFound not_found = 4; // 権限がない、もしくはキーが不正。 yamori.error.v1.CapabilityError capability_error = 5; } }
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/workspace/v1/abbreviations.proto"; import "yamori/workspace/v1/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message UpdateSingletonRequest { // ワークスペースの表示名。 string display_name = 1; // ワークスペース内で用いる省略表記。 Abbreviations abbreviations = 2; // [必須] ワークスペース自体の情報の更新を行うためのキー。 yamori.capability.v1.CapabilityKey update_key = 3 [deprecated = true]; // UpdateSingletonResponse.ok に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 4; }
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v1/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; message UpdateSingletonResponse { oneof result { Workspace ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 権限がない、もしくはキーが不正。 yamori.error.v1.CapabilityError capability_error = 3; // ワークスペースが未設定。 yamori.error.v1.NotFound not_found = 4; } }
-
-
-
@@ -1,52 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/capability/v1/capability_key.proto"; import "yamori/paid_leave_provision/v1/paid_leave_provision_table.proto"; import "yamori/work_record/v1/leave.proto"; import "yamori/workspace/v1/abbreviations.proto"; import "yamori/workspace/v1/custom_field_definition.proto"; import "yamori/workspace/v1/workspace_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; // 労働者や勤怠記録といった全てのデータの管理単位。 // 会社や組織と考えて問題ない。 message Workspace { WorkspaceID id = 1; // ワークスペースの表示名。ユーザが選択する際などに識別 // するための human readable な名前。 string display_name = 2; // ワークスペース内で用いる省略表記。 Abbreviations abbreviations = 9; // ワークスペース自体の情報の更新を行うためのキー。 yamori.capability.v1.CapabilityKey update_key = 3 [deprecated = true]; // 削除を行うためのキー。 yamori.capability.v1.CapabilityKey deletion_key = 4 [deprecated = true]; // 労働者の登録を行うためのキー。 yamori.capability.v1.CapabilityKey worker_add_key = 5 [deprecated = true]; // ワークスペース上に定義されている休暇・休業の一覧。 repeated yamori.work_record.v1.Leave leave_definitions = 6; // 定義をこのワークスペースに追加するためのキー。 yamori.capability.v1.CapabilityKey create_leave_definition_key = 7 [deprecated = true]; // 有給休暇の付与日数テーブルの一覧。 repeated yamori.paid_leave_provision.v1.PaidLeaveProvisionTable paid_leave_provision_tables = 8; // ワークスペース内の労働者に設定できるカスタムフィールドの一覧。 repeated CustomFieldDefinition custom_field_definitions = 10; // カスタムフィールドの定義の変更を行うためのキー。 yamori.capability.v1.CapabilityKey custom_field_definitions_write_key = 11 [deprecated = true]; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; // システムによって割り振られたシステム内で一意の識別子。 // ワークスペースの ID は `ws-` というプリフィクスを持つ。 message WorkspaceID { string value = 1; }
-
-
-
@@ -1,23 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/paid_leave_provision/v1/paid_leave_provision_table_read_mask.proto"; import "yamori/work_record/v1/leave_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; // Workspace を出力する際のフィールドマスク。 message WorkspaceReadMask { // レスポンスに含める Workspace のフィールド番号。 repeated int32 fields = 1; // leave_definitions 内の各要素に対してかけるマスク。 yamori.work_record.v1.LeaveReadMask leave_definitions_mask = 2; // paid_leave_provision_tables の各要素にかけるマスク。 yamori.paid_leave_provision.v1.PaidLeaveProvisionTableReadMask paid_leave_provision_table_mask = 3; }
-
-
-
@@ -1,62 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v1; import "yamori/workspace/v1/create_custom_field_definition_request.proto"; import "yamori/workspace/v1/create_custom_field_definition_response.proto"; import "yamori/workspace/v1/create_leave_definition_request.proto"; import "yamori/workspace/v1/create_leave_definition_response.proto"; import "yamori/workspace/v1/create_request.proto"; import "yamori/workspace/v1/create_response.proto"; import "yamori/workspace/v1/delete_custom_field_definition_request.proto"; import "yamori/workspace/v1/delete_custom_field_definition_response.proto"; import "yamori/workspace/v1/delete_leave_definition_request.proto"; import "yamori/workspace/v1/delete_leave_definition_response.proto"; import "yamori/workspace/v1/delete_request.proto"; import "yamori/workspace/v1/delete_response.proto"; import "yamori/workspace/v1/get_request.proto"; import "yamori/workspace/v1/get_response.proto"; import "yamori/workspace/v1/list_request.proto"; import "yamori/workspace/v1/list_response.proto"; import "yamori/workspace/v1/update_custom_field_definition_request.proto"; import "yamori/workspace/v1/update_custom_field_definition_response.proto"; import "yamori/workspace/v1/update_request.proto"; import "yamori/workspace/v1/update_response.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v1"; service WorkspaceService { // ID を指定してワークスペースを取得する。 rpc Get(GetRequest) returns (GetResponse); // アクセス可能なワークスペースの一覧を返す。 rpc List(ListRequest) returns (ListResponse); // ワークスペースを新規作成する。 rpc Create(CreateRequest) returns (CreateResponse); // ワークスペースを更新する。 rpc Update(UpdateRequest) returns (UpdateResponse); // ワークスペースを削除する。 rpc Delete(DeleteRequest) returns (DeleteResponse); // ワークスペースに休暇休業の定義を追加する。 rpc CreateLeaveDefinition(CreateLeaveDefinitionRequest) returns (CreateLeaveDefinitionResponse); // ワークスペースに存在する休暇休業の定義を削除する。 rpc DeleteLeaveDefinition(DeleteLeaveDefinitionRequest) returns (DeleteLeaveDefinitionResponse); // ワークスペースにカスタムフィールド定義を追加する。 rpc CreateCustomFieldDefinition(CreateCustomFieldDefinitionRequest) returns (CreateCustomFieldDefinitionResponse); // ワークスペースに登録されているカスタムフィールド定義の一つを更新する。 rpc UpdateCustomFieldDefinition(UpdateCustomFieldDefinitionRequest) returns (UpdateCustomFieldDefinitionResponse); // ワークスペースに登録されているカスタムフィールド定義の一つを削除する。 // 労働者に設定された該当のカスタムフィールドも全て削除される。 rpc DeleteCustomFieldDefinition(DeleteCustomFieldDefinitionRequest) returns (DeleteCustomFieldDefinitionResponse); }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message Abbreviations { // 休日の省略表示。 string dayoff = 1; // 出勤の省略表示。 string worked = 2; // 欠勤の省略表示。 string skip_work = 3; // 年次有給休暇 (取得) の省略表示。 string paid_leave = 4; }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message CreateInitialAdminRequest { // [必須] ログインユーザ名。社員 ID でもハンドルネームでも本名でも。 string name = 1; // 公開名。未設定の場合は `name` の値となる。 string display_name = 2; // [必須] 初期パスワード。 string password = 3; // 管理ユーザセットアップパスワード。 string initial_admin_password = 4; }
-
-
-
@@ -1,43 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/user.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message CreateInitialAdminResponse { oneof result { // 作成された管理者ユーザ。 User ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // 既に管理者ユーザが存在し、初期設定パスワードが利用できない。 yamori.error.v1.AuthenticationError password_expired = 3; // パスワードが異なる。 yamori.error.v1.AuthenticationError authentication_error = 4; // リクエストの必須フィールドが欠けている。 yamori.error.v1.MissingFieldError missing_field_error = 5; // 名前の前後に空白が入っている。 // 値は実装依存のデバッグメッセージ。 string name_surrounded_by_spaces = 6; // 同じ名前のユーザが既に登録されている。 // 値は重複している名前。 string duplicated_name = 7; // `password` フィールドのバイト数が指定の値未満である。 uint32 password_less_than_bytes = 8; } }
-
-
-
@@ -1,34 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/custom_attribute.proto"; import "yamori/workspace/v2/user_permissions.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message CreateUserRequest { // [必須] ログインユーザ名。社員 ID でもハンドルネームでも本名でも。 string name = 1; // 公開名。未設定の場合は `name` の値となる。 string display_name = 2; // [必須] 初期パスワード。 string password = 3; // 管理者ユーザかどうか。 bool is_admin = 4; // 通常ユーザの権限。管理者ユーザの場合は無視される。 UserPermissions permissions = 5; // カスタムフィールドの一覧。 // `definition` フィールドのメッセージ内容は `id` 以外無視される。 // ワークスペースに定義されているがここに含まれていないものは空白値 // として登録される。 repeated CustomAttribute custom_attributes = 6; }
-
-
-
@@ -1,53 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/permission_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/user.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message CreateUserResponse { oneof result { // 作成したユーザ。 User ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 yamori.error.v1.AuthenticationError authentication_error = 3; // リクエストの必須フィールドが欠けている。 yamori.error.v1.MissingFieldError missing_field_error = 4; // 権限を持っていない。 // `permission_error` を使うこと。 yamori.error.v1.CapabilityError capability_error = 5 [deprecated = true]; // 権限を持っていない。 yamori.error.v1.PermissionError permission_error = 9; // 名前の前後に空白が入っている。 // 値は実装依存のデバッグメッセージ。 string name_surrounded_by_spaces = 6; // 同じ名前のユーザが既に登録されている。 // 値は重複している名前。 string duplicated_name = 7; // `password` フィールドのバイト数が指定の値未満である。 uint32 password_less_than_bytes = 8; // カスタムフィールドの ID に合致する定義が登録されていない。 yamori.error.v1.NotFound custom_attribute_not_found = 10; } }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/custom_attribute_definition.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message CustomAttribute { // [必須] ワークスペースで設定されている、このカスタムフィールドの定義。 CustomAttributeDefinition definition = 1; // カスタムフィールドの値。 string value = 2; }
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/custom_attribute_definition_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message CustomAttributeDefinition { CustomAttributeDefinitionID id = 1; // 表示名。実際にユーザが目にするもの。 string display_name = 2; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // システムによって割り振られたシステム内で一意の識別子。 // カスタムフィールド定義の ID は `cf-` というプリフィクスを持つ。 message CustomAttributeDefinitionID { string value = 1; }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/custom_attribute_definition_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message DeleteCustomAttributeDefinitionRequest { // [必須] 削除対象の ID 。 CustomAttributeDefinitionID id = 1; }
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/permission_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/custom_attribute_definition.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message DeleteCustomAttributeDefinitionResponse { oneof result { // 削除された定義。 CustomAttributeDefinition ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // リクエストの必須フィールドが欠けている。 yamori.error.v1.MissingFieldError missing_field_error = 3; // 権限をもっていない。 yamori.error.v1.PermissionError permission_error = 4; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 yamori.error.v1.AuthenticationError authentication_error = 5; // 指定された ID の定義が存在しない。 yamori.error.v1.NotFound not_found = 6; } }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/user_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message DeleteUserRequest { // [必須] 削除対象の ID 。 UserID id = 1; }
-
-
-
@@ -1,41 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/permission_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/user.proto"; import "yamori/workspace/v2/user_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message DeleteUserResponse { oneof result { // 削除されたユーザ。 User ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // リクエストの必須フィールドが欠けている。 yamori.error.v1.MissingFieldError missing_field_error = 3; // 権限をもっていない。 yamori.error.v1.PermissionError permission_error = 4; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 yamori.error.v1.AuthenticationError authentication_error = 5; // 指定された ID のユーザが存在しない。 yamori.error.v1.NotFound not_found = 6; // この削除操作を行うと管理者が一人も存在しなくなってしまうため処理が中断された。 UserID you_are_the_only_admin = 7; } }
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message GetLoginUserRequest {}
-
-
-
@@ -1,30 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/user.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message GetLoginUserResponse { oneof result { // ログイン中のユーザ User ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 // クライアント側としての対処は再ログインしかないため、一律にログインしていないと // 扱って問題ない。 yamori.error.v1.AuthenticationError authentication_error = 3; // ワークスペースにユーザが存在しないためログインが不可能。 yamori.error.v1.AuthenticationError no_user_in_workspace = 4; } }
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message GetRequest { // GetResponse.ok に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 1; }
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message GetResponse { oneof result { Workspace ok = 1; // システムエラーが発生したため取得処理が中断された。 yamori.error.v1.SystemError system_error = 3; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 yamori.error.v1.AuthenticationError authentication_error = 2; } }
-
-
-
@@ -1,13 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message LoginMethod { // パスワードによるログインが設定されているか。 bool password_configured = 1; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/login_session_lifespan.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message LoginRequest { // ユーザ名。 string name = 1; string password = 2; LoginSessionLifespan session_lifespan = 3; }
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/user.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message LoginResponse { oneof result { // ログインしたユーザ。 User ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // ユーザ名やパスワードが異なっているといったエラー。 yamori.error.v1.AuthenticationError authentication_error = 3; // ログインに必要な要素が欠けている。 yamori.error.v1.MissingFieldError missing_field_error = 4; } }
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; enum LoginSessionLifespan { LOGIN_SESSION_LIFESPAN_UNKNOWN = 0; LOGIN_SESSION_LIFESPAN_SESSION = 1; LOGIN_SESSION_LIFESPAN_IMMORTAL = 2; LOGIN_SESSION_LIFESPAN_SHORT = 3; LOGIN_SESSION_LIFESPAN_MEDIUM = 4; LOGIN_SESSION_LIFESPAN_LONG = 5; }
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message LogoutRequest {}
-
-
-
@@ -1,16 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/system_error.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message LogoutResponse { // 予期せぬシステムエラー。 // このフィールドが空の場合は正常にログアウトできたということになる。 yamori.error.v1.SystemError system_error = 1; }
-
-
-
@@ -1,43 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "google/protobuf/timestamp.proto"; import "yamori/type/v1/date.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message PaidLeaveProvision { // 付与日。 yamori.type.v1.Date provided_at = 1; // 失効日。 yamori.type.v1.Date expires_at = 2; // 付与された年次有給休暇の日数。 int32 amount_days = 3; // 付与された年次有給休暇の残日数。 int32 remaining_days = 4; // 年次有給休暇の半日取得を行った際のもう半日分が余っているかどうか。 // 半日分も含めた残日数は以下のようにして計算できる。 // ``` // a = 0.5 if is_halved_day_remaining // 0 otherwise // x = remaining_days + a // ``` bool is_halved_day_remaining = 5; // 付与が行われた日時。 google.protobuf.Timestamp created_at = 6; // 内容の最終更新日時。 google.protobuf.Timestamp updated_at = 7; // この付与に関するメモ。 string note = 8; }
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/custom_attribute_definition_id.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message PutCustomAttributeDefinitionRequest { // 更新対象の ID 。未指定の場合は新しく作成される。 CustomAttributeDefinitionID id = 1; // [必須] 表示名。 string display_name = 2; }
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/permission_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/custom_attribute_definition.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message PutCustomAttributeDefinitionResponse { oneof result { // 作成もしくは更新された定義。 CustomAttributeDefinition ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // リクエストの必須フィールドが欠けている。 yamori.error.v1.MissingFieldError missing_field_error = 3; // 同じ表示名の定義が既に存在する。 // 値は重複している表示名。 string duplicated_display_name = 4; // 権限をもっていない。 yamori.error.v1.PermissionError permission_error = 5; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 yamori.error.v1.AuthenticationError authentication_error = 6; } }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/abbreviations.proto"; import "yamori/workspace/v2/workspace_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message UpdateRequest { // ワークスペースの表示名。 string display_name = 1; // ワークスペース内で用いる省略表記。 Abbreviations abbreviations = 2; // UpdateResponse.ok に対してかけるフィールドマスク。 WorkspaceReadMask read_mask = 3; }
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/capability_error.proto"; import "yamori/error/v1/permission_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/workspace.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message UpdateResponse { oneof result { Workspace ok = 1; // システムエラーが発生した。 yamori.error.v1.SystemError system_error = 2; // 権限がない、もしくはキーが不正。 // `permission_error` を使うこと。 yamori.error.v1.CapabilityError capability_error = 3 [deprecated = true]; // 権限を持っていない。 yamori.error.v1.PermissionError permission_error = 4; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 yamori.error.v1.AuthenticationError authentication_error = 5; } }
-
-
-
@@ -1,45 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/custom_attribute.proto"; import "yamori/workspace/v2/user_id.proto"; import "yamori/workspace/v2/user_permissions.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message UpdateUserRequest { // 更新する対象のフィールド番号。 // 未指定の場合は有効なフィールド番号が全て指定される。 repeated int32 update_fields = 1; // `permissions` フィールド内の更新するフィールド番号。 // フィールド番号は `UserPermissions` のものとなるため注意。 // 未指定の場合は有効なフィールド番号が全て指定される。 repeated int32 permission_update_fields = 2; // 更新する対象のユーザ ID 。 UserID id = 3; // ログインユーザ名。社員 ID でもハンドルネームでも本名でも。 string name = 4; // 公開名。未設定の場合は `name` の値となる。 string display_name = 5; // 管理者ユーザかどうか。 bool is_admin = 6; // 通常ユーザの権限。管理者ユーザの場合は無視される。 UserPermissions permissions = 7; // カスタムフィールドの一覧。 // `definition` フィールドのメッセージ内容は `id` 以外無視される。 // ワークスペースに定義されているがここに含まれていないものは更新されない。 // フィールドの値を削除する場合は空文字、もしくは `value` が未指定の // メッセージを含めれば良い。 repeated CustomAttribute custom_attributes = 8; }
-
-
-
@@ -1,49 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/error/v1/authentication_error.proto"; import "yamori/error/v1/missing_field_error.proto"; import "yamori/error/v1/not_found.proto"; import "yamori/error/v1/permission_error.proto"; import "yamori/error/v1/system_error.proto"; import "yamori/workspace/v2/user.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; message UpdateUserResponse { oneof result { // 更新した後のユーザ。 User ok = 1; // 予期せぬエラー。 yamori.error.v1.SystemError system_error = 2; // ログインしていない、トークンやセッションが不正といった認証に関するエラーが発生した。 yamori.error.v1.AuthenticationError authentication_error = 3; // フィールド番号が指定されているにも関わらず、該当のフィールドが未指定、 // もしくは空値である。 yamori.error.v1.MissingFieldError missing_field_error = 4; // 該当する ID のユーザが存在しない。 yamori.error.v1.NotFound not_found = 5; // 権限を持っていない。 yamori.error.v1.PermissionError permission_error = 6; // 名前の前後に空白が入っている。 // 値は実装依存のデバッグメッセージ。 string name_surrounded_by_spaces = 7; // 同じ名前のユーザが既に登録されている。 // 値は重複している名前。 string duplicated_name = 8; // カスタムフィールドの ID に合致する定義が登録されていない。 yamori.error.v1.NotFound custom_attribute_not_found = 9; } }
-
-
-
@@ -1,48 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/custom_attribute.proto"; import "yamori/workspace/v2/login_method.proto"; import "yamori/workspace/v2/user_id.proto"; import "yamori/workspace/v2/user_permissions.proto"; import "yamori/workspace/v2/worker.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // ワークスペースにログイン可能なユーザ。 message User { // ワークスペースで一意な ID 。 UserID id = 1; // ログインユーザ名。社員 ID でもハンドルネームでも本名でも。 string name = 2; // 公開名。 string display_name = 3; // ログイン設定。 LoginMethod login_method = 4; // 管理者ユーザかどうか。ワークスペースには必ず一人以上の管理者ユーザが // 存在する。 // 管理者ユーザは全てのデータに対する全ての操作が行える。 // 全ての権限を持った通常ユーザとの違いは、将来のアップデートによって // 新しい権限が追加された際、管理者ユーザはその権限を自動的に持つが // 通常ユーザには与えられない。これは新しい権限を持つユーザが存在せず、 // その新しい権限を持つユーザを誰も作成できないデッドロック状態を回避 // するための設計となる。 bool is_admin = 5; UserPermissions permissions = 6; // 労働者としての属性。このフィールドが空の場合はこのユーザは労働者ではない、 // ということになり記録はつけられない。 Worker worker = 7; // カスタムフィールドの一覧。 repeated CustomAttribute custom_attributes = 8; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // システムによって割り振られたワークスペース内で一意の識別子。 // ユーザ ID は `wu-` というプリフィクスを持つ。 message UserID { string value = 1; }
-
-
-
@@ -1,44 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // ユーザの権限。 // 実際にユーザ管理でユーザが目にするのはこれ。 // 操作時にチェックを行うのはこれを基に生成されたキー。 // 管理者ユーザの場合はこの情報は利用されずに必ずキーが発行される。 message UserPermissions { // ワークスペースにユーザを追加できるか。 // 作成される権限は作成するユーザの権限が最大となるため、 // 管理者ユーザは管理者ユーザによってしか追加できない。 bool can_add_user = 1; // ユーザの削除が行えるか。 // 管理者ユーザの削除は管理者ユーザのみ行える。 bool can_delete_regular_user = 2; // 実際の記録を含めない、自分以外のユーザ情報を参照できるか。 // これがない場合は編集や記録の参照も行えない。 bool can_read_other_user_profile = 3; // 実際の記録を含めない、自分以外のユーザ情報を編集できるか。 // 管理者ユーザに対する変更は管理者ユーザのみ行える。 bool can_update_other_regular_user_profile = 4; // 自分自身のユーザ情報を編集できるか。 bool can_update_self_profile = 5; // 自分以外のユーザのログイン手段の更新を行えるか。 // パスワードの変更や手段を削除してログイン不可にすることも含まれる。 // 管理者ユーザに対する変更は管理者ユーザのみ行える。 bool can_update_other_regular_user_login_method = 6; // ワークスペースの全体設定を変更できるかどうか。 // 休暇・休業の定義一覧や年次有給休暇の付与日数テーブル、省略表記など // も含まれる。 bool can_update_workspace = 7; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // User を出力する際のフィールドマスク。 message UserReadMask { // レスポンスに含める User のフィールド番号。 repeated int32 fields = 1; }
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/paid_leave_provision/v1/paid_leave_provision_table.proto"; import "yamori/type/v1/date.proto"; import "yamori/work_record/v2/work_record.proto"; import "yamori/workspace/v2/paid_leave_provision.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // 労働者としての情報、属性。 message Worker { // 勤怠記録。 repeated yamori.work_record.v2.WorkRecord work_records = 1; // 年次有給休暇の付与テーブル。 yamori.paid_leave_provision.v1.PaidLeaveProvisionTable paid_leave_provision_table = 2; // 初回の年次有給休暇の付与日。 yamori.type.v1.Date first_paid_leave_provision_at = 3; // 年次有給休暇の付与記録。 repeated PaidLeaveProvision paid_leave_provisions = 4; }
-
-
-
@@ -1,40 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/paid_leave_provision/v1/paid_leave_provision_table.proto"; import "yamori/work_record/v1/leave.proto"; import "yamori/workspace/v2/abbreviations.proto"; import "yamori/workspace/v2/custom_attribute_definition.proto"; import "yamori/workspace/v2/user.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // 労働者や勤怠記録といった全てのデータの管理単位。 // 会社や組織と考えて問題ない。 message Workspace { // ワークスペースの表示名。ユーザが選択する際などに識別 // するための human readable な名前。 string display_name = 1; // ワークスペース内で用いる省略表記。 Abbreviations abbreviations = 2; // ワークスペース上に定義されている休暇・休業の一覧。 repeated yamori.work_record.v1.Leave leave_definitions = 3; // 有給休暇の付与日数テーブルの一覧。 repeated yamori.paid_leave_provision.v1.PaidLeaveProvisionTable paid_leave_provision_tables = 4; // ワークスペースに登録されているユーザの一覧。 repeated User users = 5; // 管理者ユーザが存在するか。初期セットアップ時のみ利用。 bool has_admin = 6 [deprecated = true]; // ワークスペース内のユーザに設定できるカスタムフィールドの一覧。 repeated CustomAttributeDefinition custom_attribute_definition = 7; }
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/paid_leave_provision/v1/paid_leave_provision_table_read_mask.proto"; import "yamori/work_record/v1/leave_read_mask.proto"; import "yamori/workspace/v2/user_read_mask.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; // Workspace を出力する際のフィールドマスク。 message WorkspaceReadMask { // レスポンスに含める Workspace のフィールド番号。 repeated int32 fields = 1; // leave_definitions 内の各要素に対してかけるマスク。 yamori.work_record.v1.LeaveReadMask leave_definitions_mask = 2; // paid_leave_provision_tables の各要素にかけるマスク。 yamori.paid_leave_provision.v1.PaidLeaveProvisionTableReadMask paid_leave_provision_table_mask = 3; // users の各要素にかけるマスク。 UserReadMask users_mask = 4; }
-
-
-
@@ -1,65 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.workspace.v2; import "yamori/workspace/v2/create_initial_admin_request.proto"; import "yamori/workspace/v2/create_initial_admin_response.proto"; import "yamori/workspace/v2/create_user_request.proto"; import "yamori/workspace/v2/create_user_response.proto"; import "yamori/workspace/v2/delete_custom_attribute_definition_request.proto"; import "yamori/workspace/v2/delete_custom_attribute_definition_response.proto"; import "yamori/workspace/v2/delete_user_request.proto"; import "yamori/workspace/v2/delete_user_response.proto"; import "yamori/workspace/v2/get_login_user_request.proto"; import "yamori/workspace/v2/get_login_user_response.proto"; import "yamori/workspace/v2/get_request.proto"; import "yamori/workspace/v2/get_response.proto"; import "yamori/workspace/v2/login_request.proto"; import "yamori/workspace/v2/login_response.proto"; import "yamori/workspace/v2/logout_request.proto"; import "yamori/workspace/v2/logout_response.proto"; import "yamori/workspace/v2/put_custom_attribute_definition_request.proto"; import "yamori/workspace/v2/put_custom_attribute_definition_response.proto"; import "yamori/workspace/v2/update_request.proto"; import "yamori/workspace/v2/update_response.proto"; import "yamori/workspace/v2/update_user_request.proto"; import "yamori/workspace/v2/update_user_response.proto"; option go_package = "pocka.jp/x/yamori/proto/go/workspace/v2"; service WorkspaceService { // ワークスペースにログインする。 rpc Login(LoginRequest) returns (LoginResponse); // ワークスペースからログアウトする。 rpc Logout(LogoutRequest) returns (LogoutResponse); // ワークスペース情報を取得する。 rpc Get(GetRequest) returns (GetResponse); // ワークスペース情報を更新する。 rpc Update(UpdateRequest) returns (UpdateResponse); rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); // ユーザの登録情報を更新する。 rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse); // ユーザを削除する。 rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse); // ログインしているユーザ情報を返す。 rpc GetLoginUser(GetLoginUserRequest) returns (GetLoginUserResponse); // ワークスペースに管理者が一人もいない状態で、最初の管理者ユーザを作成する。 rpc CreateInitialAdmin(CreateInitialAdminRequest) returns (CreateInitialAdminResponse); // カスタムフィールドの定義を作成、もしくは更新する。 rpc PutCustomAttributeDefinition(PutCustomAttributeDefinitionRequest) returns (PutCustomAttributeDefinitionResponse); // カスタムフィールドの定義を削除する rpc DeleteCustomAttributeDefinition(DeleteCustomAttributeDefinitionRequest) returns (DeleteCustomAttributeDefinitionResponse); }
-
-
packages/pwa/.gitignore (deleted)
-
@@ -1,8 +0,0 @@# このディレクトリ特有の無視設定。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only # What: ビルドされたページとアセット。 # Why: 編集するものではないため。 /dist
-
-
-
@@ -1,25 +0,0 @@# This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { version = "5.82.2" constraints = "~> 5.82" hashes = [ "h1:ce6Dw2y4PpuqAPtnQ0dO270dRTmwEARqnfffrE1VYJ8=", "zh:0262fc96012fb7e173e1b7beadd46dfc25b1dc7eaef95b90e936fc454724f1c8", "zh:397413613d27f4f54d16efcbf4f0a43c059bd8d827fe34287522ae182a992f9b", "zh:436c0c5d56e1da4f0a4c13129e12a0b519d12ab116aed52029b183f9806866f3", "zh:4d942d173a2553d8d532a333a0482a090f4e82a2238acf135578f163b6e68470", "zh:624aebc549bfbce06cc2ecfd8631932eb874ac7c10eb8466ce5b9a2fbdfdc724", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", "zh:9e632dee2dfdf01b371cca7854b1ec63ceefa75790e619b0642b34d5514c6733", "zh:a07567acb115b60a3df8f6048d12735b9b3bcf85ec92a62f77852e13d5a3c096", "zh:ab7002df1a1be6432ac0eb1b9f6f0dd3db90973cd5b1b0b33d2dae54553dfbd7", "zh:bc1ff65e2016b018b3e84db7249b2cd0433cb5c81dc81f9f6158f2197d6b9fde", "zh:bcad84b1d767f87af6e1ba3dc97fdb8f2ad5de9224f192f1412b09aba798c0a8", "zh:cf917dceaa0f9d55d9ff181b5dcc4d1e10af21b6671811b315ae2a6eda866a2a", "zh:d8e90ecfb3216f3cc13ccde5a16da64307abb6e22453aed2ac3067bbf689313b", "zh:d9054e0e40705df729682ad34c20db8695d57f182c65963abd151c6aba1ab0d3", "zh:ecf3a4f3c57eb7e89f71b8559e2a71e4cdf94eea0118ec4f2cb37e4f4d71a069", ] }
-
-
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
-
@@ -1,91 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as zod from "zod"; const DIST_DIR = new URL("../../dist", import.meta.url); const outputFileSchema = zod.object({ cloudfront_distribution_id: zod.object({ type: zod.literal("string"), value: zod.string(), }), static_file_bucket_name: zod.object({ type: zod.literal("string"), value: zod.string(), }), }); interface TerraformOutput { bucketName: string; cfDistributionID: string; } async function getTerraformOutput(): Promise<TerraformOutput> { const output = Bun.spawnSync(["terraform", "output", "-json"], { stdin: "inherit", }); const stdout = JSON.parse(await new Response(output.stdout).text()); const result = outputFileSchema.safeParse(stdout); if (!result.success) { throw result.error; } return { bucketName: result.data.static_file_bucket_name.value, cfDistributionID: result.data.cloudfront_distribution_id.value, }; } async function deploy(opts: TerraformOutput): Promise<void> { const s3Sync = Bun.spawnSync( ["aws", "s3", "sync", "--delete", DIST_DIR.pathname, `s3://${opts.bucketName}`], { stdin: "inherit", stdout: "inherit", stderr: "inherit", }, ); if (!s3Sync.success) { throw new Error("aws s3 sync コマンドが異常終了しました"); } const cfInvalidation = Bun.spawnSync( [ "aws", "cloudfront", "create-invalidation", "--distribution-id", opts.cfDistributionID, "--paths", "/*", ], { stdin: "inherit", stdout: "inherit", stderr: "inherit", }, ); if (!cfInvalidation.success) { throw new Error("aws cloudfront create-invalidation コマンドが異常終了しました"); } } async function main() { const output = await getTerraformOutput(); await deploy(output); } if (import.meta.main) { try { main(); } catch (error) { console.error(error); process.exit(1); } }
-
-
-
@@ -1,22 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only function handler(event) { var path = event.request.uri; if (path.indexOf("/assets/") === 0) { return event.request; } var pathSegments = path.split("/"); if ( pathSegments.length === 0 || pathSegments[pathSegments.length - 1].indexOf(".") >= 0 ) { return event.request; } event.request.uri = "/index.html"; return event.request; }
-
-
packages/pwa/infrastructure/aws/main.tf (deleted)
-
@@ -1,161 +0,0 @@# S3+Cloudfront 構成のサイトインフラ。独自ドメインを CNAME で Cloudfront の # ドメインに向ける前提。 ACM のバリデーションは output をコピペで手動。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.82" } } required_version = ">= 1.2.0" } provider "aws" { region = var.aws_region default_tags { tags = { Service = "Yamori" Module = "PWA" } } } provider "aws" { alias = "us_east_1" region = "us-east-1" default_tags { tags = { Service = "Yamori" Module = "PWA" } } } resource "aws_s3_bucket" "origin" {} output "static_file_bucket_name" { value = aws_s3_bucket.origin.id } data "aws_iam_policy_document" "s3_cf_read_policy" { statement { sid = "AllowCloudfrontReadonly" principals { type = "Service" identifiers = ["cloudfront.amazonaws.com"] } actions = ["s3:GetObject"] resources = ["${aws_s3_bucket.origin.arn}/*"] condition { test = "StringEquals" variable = "aws:SourceArn" values = [aws_cloudfront_distribution.cdn.arn] } } } resource "aws_s3_bucket_policy" "allow_read_from_cloudfront" { bucket = aws_s3_bucket.origin.id policy = data.aws_iam_policy_document.s3_cf_read_policy.json } resource "aws_acm_certificate" "domain_cert" { # CloudFront で使う ACM は us-east-1 にある必要がある。 # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html provider = aws.us_east_1 domain_name = var.pwa_domain validation_method = "DNS" lifecycle { create_before_destroy = true } } output "domain_cert_validations" { value = aws_acm_certificate.domain_cert.domain_validation_options } locals { cf_origin_id = "yamor_pwa_cdn" } resource "aws_cloudfront_origin_access_control" "s3_oac" { name = "static_website" origin_access_control_origin_type = "s3" signing_behavior = "always" signing_protocol = "sigv4" } resource "aws_cloudfront_function" "spa_routing" { name = "spa_routing" code = file("${path.module}/functions/spa_routing.js") runtime = "cloudfront-js-2.0" } resource "aws_cloudfront_distribution" "cdn" { origin { domain_name = aws_s3_bucket.origin.bucket_regional_domain_name origin_id = local.cf_origin_id origin_access_control_id = aws_cloudfront_origin_access_control.s3_oac.id } enabled = true is_ipv6_enabled = true default_root_object = "index.html" http_version = "http2and3" aliases = [var.pwa_domain] viewer_certificate { acm_certificate_arn = aws_acm_certificate.domain_cert.arn ssl_support_method = "sni-only" } default_cache_behavior { # AWS が管理している CacheOptimized ポリシー cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" allowed_methods = ["GET", "HEAD", "OPTIONS"] cached_methods = ["GET", "HEAD"] target_origin_id = local.cf_origin_id compress = true viewer_protocol_policy = "redirect-to-https" min_ttl = 0 default_ttl = 31536000 max_ttl = 31536000 function_association { event_type = "viewer-request" function_arn = aws_cloudfront_function.spa_routing.arn } } restrictions { geo_restriction { locations = [] restriction_type = "none" } } } output "cloudfront_distribution_id" { value = aws_cloudfront_distribution.cdn.id } output "cloudfront_domain" { value = aws_cloudfront_distribution.cdn.domain_name }
-
-
packages/pwa/infrastructure/aws/vars.tf (deleted)
-
@@ -1,13 +0,0 @@# SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only variable "pwa_domain" { description = "PWAをホストする最終的なドメイン" type = string } variable "aws_region" { description = "デフォルトのリージョン" type = string default = "us-west-2" }
-
-
packages/pwa/package.json (deleted)
-
@@ -1,78 +0,0 @@{ "name": "@yamori/pwa", "private": true, "type": "module", "scripts": { "check": "wireit", "dev": "wireit", "make": "wireit", "clean": "rm -rf dist" }, "wireit": { "make": { "command": "vite build", "files": ["src/**/*.{ts,tsx,css,html,json}", "package.json", "vite.config.ts"], "output": ["dist/**"], "dependencies": [ "../idb_backend:js", "../react_ui:js", "../backend:js", "../backend:wasm" ], "packageLocks": ["bun.lockb"] }, "check": { "command": "tsc", "files": ["src/**/*.{ts,tsx,css,html,json}", "package.json"], "output": [], "dependencies": ["tsconfig", "../idb_backend:dts", "../react_ui:dts"], "packageLocks": ["bun.lockb"] }, "tsconfig": { "files": ["tsconfig.json", "../../tsconfig.jsonc"] }, "dev": { "command": "vite", "service": true, "dependencies": [ { "script": "../react_ui:js", "cascade": false }, { "script": "../idb_backend:js", "cascade": false }, { "script": "../backend:js", "cascade": false }, { "script": "../backend:wasm", "cascade": false } ], "packageLocks": ["bun.lockb"] } }, "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@yamori/backend": "workspace:*", "@yamori/idb_backend": "workspace:*", "@yamori/proto": "workspace:*", "@yamori/react_ui": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "rollup-plugin-license": "^3.5.3", "typescript": "^5.7.2", "vite": "^6.0.2", "zod": "^3.24.1" } }
-
-
packages/pwa/package.json.license (deleted)
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/pwa/src/index.html (deleted)
-
@@ -1,39 +0,0 @@<!DOCTYPE html> <html lang="en-US"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta http-equiv="content-security-policy" content="default-src 'none'; img-src 'self' data:; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; style-src 'self' 'unsafe-inline'; worker-src 'self'; font-src 'self'" /> <script type="module" src="./main.tsx"></script> <link rel="stylesheet" href="./styles.css" /> <title>Yamori</title> <link rel="icon" type="image/svg+xml" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)" /> <link rel="icon" type="image/png" href="/favicon-dark.png" media="(prefers-color-scheme: dark)" /> <link rel="icon" type="image/svg+xml" href="/favicon-light.svg" media="(prefers-color-scheme: light)" /> <link rel="icon" type="image/png" href="/favicon-light.png" media="(prefers-color-scheme: light)" /> </head> <body></body> </html>
-
-
packages/pwa/src/index.html.license (deleted)
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/pwa/src/main.module.css (deleted)
-
@@ -1,12 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .theme { position: absolute; inset: 0; box-sizing: border-box; overflow: auto; }
-
-
packages/pwa/src/main.tsx (deleted)
-
@@ -1,79 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createConnectTransport } from "@connectrpc/connect-web"; import { ConnectTransportProvider, ProtoRPCProvider, type ProtoRPC, ThemeProvider, ThirdPartyNoticeProvider, HistoryAPIRouterProvider, Page, } from "@yamori/react_ui"; import { createRoot } from "react-dom/client"; import css from "./main.module.css"; import { Message, isValidMessage } from "./worker/message.ts"; async function readThirdPartyNotice() { const resp = await fetch("/third-party.txt"); return resp.text(); } const worker = new Worker(new URL("./worker/main.ts", import.meta.url), { type: "module", }); const transport = createConnectTransport({ baseUrl: "/api", }); const root = createRoot(document.body); worker.addEventListener("message", (event) => { if (event.data !== "ready") { return; } const rpc: ProtoRPC = { send(service, method, data) { const id = crypto.randomUUID(); return new Promise((resolve) => { const onMessage = (event: MessageEvent) => { if (!isValidMessage(event.data) || event.data.id !== id) { return; } resolve(event.data.data); worker.removeEventListener("message", onMessage); }; worker.addEventListener("message", onMessage); worker.postMessage({ id, service, method, data, } satisfies Message); }); }, }; root.render( <ThirdPartyNoticeProvider text={readThirdPartyNotice}> <HistoryAPIRouterProvider> <ConnectTransportProvider transport={transport}> <ProtoRPCProvider rpc={rpc}> <ThemeProvider className={css.theme}> <Page /> </ThemeProvider> </ProtoRPCProvider> </ConnectTransportProvider> </HistoryAPIRouterProvider> </ThirdPartyNoticeProvider>, ); });
-
-
packages/pwa/src/styles.css (deleted)
-
@@ -1,6 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ @import "@yamori/react_ui/styles.css";
-
-
packages/pwa/src/vite.d.ts (deleted)
-
@@ -1,4 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only /// <reference types="vite/client" />
-
-
packages/pwa/src/worker/backend.ts (deleted)
-
@@ -1,4 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "@yamori/backend/worker.js";
-
-
packages/pwa/src/worker/main.ts (deleted)
-
@@ -1,35 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { idbBackend } from "@yamori/idb_backend"; import { isValidMessage, type Message } from "./message.ts"; async function main() { const backend = await idbBackend(); addEventListener("message", async (ev) => { if (!isValidMessage(ev.data)) { console.warn("Invalid message sent from main thread."); return; } const resp = await backend.handle(ev.data); self.postMessage( { id: ev.data.id, service: ev.data.service, method: ev.data.method, data: resp, } satisfies Message, { transfer: [resp.buffer], }, ); }); self.postMessage("ready"); } main();
-
-
packages/pwa/src/worker/message.ts (deleted)
-
@@ -1,36 +0,0 @@// ワーカーとメインスレッド間でやりとりするメッセージフォーマット。 // 及びそのヘルパ関数。 // // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export interface Message { id: unknown; service: string; method: string; data: Uint8Array; } export function isValidMessage(x: unknown): x is Message { if (typeof x !== "object" || !x) { return false; } if (!("id" in x)) { return false; } if (!("service" in x && typeof x.service === "string" && x.service)) { return false; } if (!("method" in x && typeof x.method === "string" && x.method)) { return false; } if (!("data" in x && x.data instanceof Uint8Array)) { return false; } return true; }
-
-
packages/pwa/src/worker/tsconfig.json (deleted)
-
@@ -1,7 +0,0 @@{ "extends": "../../tsconfig.json", "compilerOptions": { "lib": ["ES2020", "WebWorker"] }, "includes": ["./**/*.ts"] }
-
-
-
@@ -1,7 +0,0 @@このディレクトリ配下限定の TypeScript のコンパイラ設定ファイル。 Worker はメインスレッドの DOM 環境と異なるので設定を分ける必要がある。 コメントではなく別ファイルなのは ../../tsconfig.json.license を参照。 SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/pwa/tsconfig.json (deleted)
-
@@ -1,10 +0,0 @@{ "extends": "../../tsconfig.jsonc", "compilerOptions": { "moduleResolution": "Bundler", "noEmit": true, "lib": ["ES2020", "DOM", "DOM.iterable"], "jsx": "react-jsx" }, "include": ["*.ts", "src/**/*.ts", "src/**/*.tsx"] }
-
-
packages/pwa/tsconfig.json.license (deleted)
-
@@ -1,9 +0,0 @@TypeScript のコンパイラ設定ファイル。 実際は JSON ではなく JSONC のためコメントを含められるが、 .jsonc にすると TypeScript 関連のツールがデフォルトで読まなくなる。逆に .json のまま コメントを含めるとフォーマッタといった TypeScript 以外の JSON を扱うツール でエラーになる。そのため拡張子と仕様に準拠している。 SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/pwa/vite.config.ts (deleted)
-
@@ -1,45 +0,0 @@// Web 向けのバンドラー、 Vite の設定ファイル。 // <https://vite.dev/> // // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import react from "@vitejs/plugin-react"; import license from "rollup-plugin-license"; import { defineConfig } from "vite"; export default defineConfig({ root: new URL("./src", import.meta.url).pathname, publicDir: new URL("../assets", import.meta.url).pathname, server: { proxy: { "/api": { target: "http://localhost:8765", rewrite: (path) => path.replace(/^\/api/, ""), }, }, }, build: { emptyOutDir: true, outDir: "../dist", rollupOptions: { plugins: [ // REUSE-IgnoreStart license({ banner: { content: "SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com>\nSPDX-License-Identifier: AGPL-3.0-only", commentStyle: "ignored", }, thirdParty: { output: { file: new URL("./dist/third-party.txt", import.meta.url).pathname, }, }, }), // REUSE-IgnoreEnd ], }, }, plugins: [react()], });
-
-
packages/react_ui/.gitignore (deleted)
-
@@ -1,12 +0,0 @@# このディレクトリ特有の無視設定。 # # SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> # SPDX-License-Identifier: AGPL-3.0-only # What: ビルドされたライブラリ JS 。 # Why: 編集するものではないため。 /dist # What: ビルドされた *.d.ts ファイルを格納するディレクトリ。 # Why: 自動生成されたディレクトリのため。 /types
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Decorator } from "@storybook/react"; import { idbBackend } from "@yamori/idb_backend"; import { ProtoRPCProvider, type ProtoRPC } from "../../src/lib.ts"; export function withIDBBackend<Args>(): Decorator<Args> { const backend = idbBackend(); const rpc = { async send(service, method, data) { return (await backend).handle({ service, method, data, }); }, } satisfies ProtoRPC; return function (Story) { return ( <ProtoRPCProvider rpc={rpc}> <Story /> </ProtoRPCProvider> ); }; }
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { action } from "@storybook/addon-actions"; import { type Decorator } from "@storybook/react"; import { InmemoryRouterProvider, type InmemoryRouterProviderProps, } from "../../src/contexts/Router.tsx"; export type WithInmemoryRouterOptions = Pick<InmemoryRouterProviderProps, "initialURL">; export function withInmemoryRouter<Args>({ initialURL, }: WithInmemoryRouterOptions = {}): Decorator<Args> { return (Story) => { return ( <InmemoryRouterProvider baseURL="/" initialURL={initialURL} onRouteChange={action("onRouteChange")} > <Story /> </InmemoryRouterProvider> ); }; }
-
-
-
@@ -1,111 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type DescMethod, type DescMessage, type MessageInitShape, type MessageShape, create, fromBinary, toBinary, } from "@bufbuild/protobuf"; import { type Transport, type UnaryResponse } from "@connectrpc/connect"; import { type Decorator } from "@storybook/react"; import { ConnectTransportProvider, ProtoRPCProvider, type ProtoRPC, } from "../../src/lib.ts"; export interface MethodMock { service: string; method: string; handler(request: Uint8Array): Promise<Uint8Array> | Uint8Array; } export interface MockOptions { delayMs?: number; } export function mock<Input extends DescMessage, Output extends DescMessage>( method: { input: Input; output: Output } & DescMethod, handler: ( request: MessageShape<Input>, ) => Promise<MessageInitShape<Output>> | MessageInitShape<Output>, { delayMs = 0 }: MockOptions = {}, ): MethodMock { return { service: method.parent.typeName, method: method.name, async handler(data) { if (delayMs > 0) { await new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, delayMs); }); } const request = fromBinary(method.input, data); return toBinary(method.output, create(method.output, await handler(request))); }, }; } export function withMockedBackend<Args>(mocks: readonly MethodMock[]): Decorator<Args> { const transport: Transport = { async unary(method, _signal, _timeoutMs, _header, input) { for (const mock of mocks) { if (mock.service === method.parent.typeName && mock.method === method.name) { const req = toBinary(method.input, create(method.input, input)); return { method, service: method.parent, header: new Headers(), stream: false, message: fromBinary(method.output, await mock.handler(req)), trailer: new Headers(), } satisfies UnaryResponse<typeof method.input, typeof method.output>; } } throw new Error( `[withMockedBackend] No mock set to "${method.parent.typeName}.${method}"`, ); }, async stream(method, _signal, _timeoutMs, _header, _stream) { throw new Error( `[withMockedBackend] Streaming RPC is not supported ("${method.parent.typeName}.${method}")`, ); }, }; const rpc = { async send(service, method, data) { for (const mock of mocks) { if (mock.service === service && mock.method === method) { return mock.handler(data); } } throw new Error(`[withMockedBackend] No mock set to "${service}.${method}"`); }, } satisfies ProtoRPC; return function (Story) { return ( <ConnectTransportProvider transport={transport}> <ProtoRPCProvider rpc={rpc} config={{ defaultOptions: { queries: { retry: false } } }} > <Story /> </ProtoRPCProvider> </ConnectTransportProvider> ); }; }
-
-
packages/react_ui/.storybook/main.ts (deleted)
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type StorybookConfig } from "@storybook/react-vite"; export default { stories: ["../src/**/*.stories.tsx"], addons: ["@storybook/addon-essentials", "@storybook/addon-interactions"], framework: { name: "@storybook/react-vite", options: {}, }, core: { disableTelemetry: true, disableWhatsNewNotifications: true, enableCrashReports: false, }, staticDirs: ["./public"], } satisfies StorybookConfig;
-
-
-
@@ -1,6 +0,0 @@<!-- SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only --> <link rel="icon" href="/favicon-storybook.svg" />
-
-
-
@@ -1,16 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .theme { position: absolute; inset: 0; box-sizing: border-box; overflow: auto; } :global(.sb-main-padded) .theme { padding: var(--space-3); }
-
-
packages/react_ui/.storybook/preview.tsx (deleted)
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Preview } from "@storybook/react"; import { ThemeProvider } from "../src/lib.ts"; import css from "./preview.module.css"; import { withIDBBackend } from "./decorators/withIDBBackend.tsx"; export default { decorators: [ (Story) => ( <ThemeProvider className={css.theme}> <Story /> </ThemeProvider> ), withIDBBackend(), ], } satisfies Preview;
-
-
-
@@ -1,1 +0,0 @@<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="none"><rect width="100" height="100" fill="#E93D82" rx="9"/><path fill="#EDEEF0" d="M76.933 72.453V48.406c0-1.625-.203-2.906-.609-3.843-.375-.97-.953-1.672-1.734-2.11-.782-.437-1.75-.656-2.907-.656-1.218 0-2.25.25-3.093.75-.813.5-1.422 1.219-1.828 2.156-.407.906-.61 2-.61 3.281H53.73c0-2.156.422-4.187 1.266-6.093a15.072 15.072 0 0 1 3.656-5.11c1.625-1.5 3.563-2.672 5.813-3.515 2.28-.844 4.812-1.266 7.593-1.266 3.344 0 6.313.563 8.907 1.688 2.593 1.124 4.64 2.906 6.14 5.343 1.5 2.438 2.25 5.61 2.25 9.516v22.969c0 2.687.14 4.937.422 6.75.313 1.78.766 3.312 1.36 4.593v.797H78.62c-.563-1.344-.984-3.031-1.266-5.062a45.413 45.413 0 0 1-.422-6.14Zm1.547-19.781.047 7.64h-4.969c-1.375 0-2.578.204-3.609.61-1.031.406-1.906.984-2.625 1.734a7.327 7.327 0 0 0-1.547 2.672c-.344 1-.515 2.078-.515 3.234 0 1.344.187 2.47.562 3.376.375.906.938 1.593 1.688 2.062.75.438 1.656.656 2.718.656 1.563 0 2.907-.328 4.032-.984 1.156-.656 2.015-1.438 2.578-2.344.593-.937.797-1.812.61-2.625l2.812 4.922c-.313 1.188-.797 2.422-1.454 3.703a17.849 17.849 0 0 1-2.484 3.563c-1 1.093-2.219 1.984-3.656 2.671-1.438.688-3.14 1.032-5.11 1.032-2.75 0-5.25-.61-7.5-1.828-2.218-1.25-3.984-2.985-5.296-5.204-1.282-2.25-1.922-4.921-1.922-8.015 0-2.563.422-4.875 1.265-6.938.844-2.093 2.078-3.859 3.703-5.297 1.657-1.468 3.75-2.609 6.282-3.421 2.53-.813 5.5-1.22 8.906-1.22h5.484ZM22.973 15.406 33.942 46.72l10.875-31.313h14.156L40.41 58.766v24.89H27.38v-24.89L8.862 15.406h14.11Z"/></svg>
-
-
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/react_ui/package.json (deleted)
-
@@ -1,117 +0,0 @@{ "name": "@yamori/react_ui", "private": true, "type": "module", "scripts": { "dev": "wireit", "make": "wireit", "clean": "rm -rf dist types", "check": "wireit" }, "wireit": { "tsconfig": { "files": ["tsconfig.json", "../../tsconfig.jsonc"] }, "js": { "command": "vite build", "files": [ "src/**/*.{css,ts,tsx}", "!src/mocks/**", "!src/**/*.stories.{ts,tsx}", "vite.config.ts", "package.json" ], "output": ["dist/**"], "dependencies": ["../proto:js", "../idb_backend:js"], "packageLocks": ["bun.lockb"] }, "dts": { "command": "tsc -p tsconfig.build.jsonc", "files": [ "src/**/*.{css,ts,tsx}", "!src/mocks/**", "!src/**/*.stories.{ts,tsx}", "tsconfig.build.jsonc", "package.json" ], "clean": "if-file-deleted", "output": ["types/**"], "dependencies": ["tsconfig", "../proto:dts", "../idb_backend:dts"], "packageLocks": ["bun.lockb"] }, "make": { "dependencies": ["js", "dts"] }, "check": { "command": "tsc", "files": ["src/**/*.{ts,tsx}"], "output": [], "dependencies": ["../proto:dts", "../idb_backend:dts"], "packageLocks": ["bun.lockb"] }, "dev": { "command": "storybook dev --port $PORT --no-open", "service": true, "env": { "PORT": { "external": true, "default": "6006" } }, "dependencies": [ { "script": "../proto:js", "cascade": false }, { "script": "../idb_backend:js", "cascade": false } ], "packageLocks": ["bun.lockb"] } }, "exports": { ".": { "types": "./types/lib.d.ts", "default": "./dist/lib.js" }, "./styles.css": { "default": "./dist/styles.css" } }, "peerDependencies": { "react": "19.x.x", "react-dom": "19.x.x" }, "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", "@date-fns/tz": "^1.2.0", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/themes": "^3.1.6", "@tanstack/react-query": "^5.62.8", "@yamori/idb_backend": "workspace:*", "@yamori/proto": "workspace:*", "date-fns": "^4.1.0", "react-hook-form": "^7.54.2", "urlpattern-polyfill": "^10.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^8.4.7", "@storybook/addon-interactions": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", "@types/bun": "^1.1.14", "@types/react": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "react": "19.x.x", "react-dom": "^19.0.0", "typescript": "^5.7.2", "vite": "^6.0.2" } }
-
-
packages/react_ui/package.json.license (deleted)
-
@@ -1,2 +0,0 @@SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
-
@@ -1,28 +0,0 @@/** * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ :where(.textarea) { all: unset; } .textarea { line-height: var(--line-height-1); resize: none; display: block; width: 100%; border: 1px solid var(--gray-6); padding-bottom: var(--space-5); white-space: pre; background-color: var(--background-surface); border-radius: var(--radius-2); color: var(--gray-12); } @media (hover: hover) { .textarea:focus-visible { outline: 2px solid var(--focus-8); outline-offset: -1px; } }
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { CopyableText } from "./CopyableText"; export default { component: CopyableText, args: { children: JSON.stringify({ foo: { bar: "baz" } }, null, 2), mono: false, }, } satisfies Meta<typeof CopyableText>; type Story = StoryObj<typeof CopyableText>; export const Defaults: Story = {}; export const Mono: Story = { args: { mono: true, }, };
-
-
-
@@ -1,81 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { CopyIcon } from "@radix-ui/react-icons"; import { Box, type BoxProps, IconButton, Text, Tooltip } from "@radix-ui/themes"; import { type FC, useMemo, useRef } from "react"; import { useToast } from "../contexts/Toast.tsx"; import css from "./CopyableText.module.css"; export interface CopyableTextProps extends Omit<BoxProps, "children"> { children: string; mono?: boolean; } export const CopyableText: FC<CopyableTextProps> = ({ children, mono = false, ...rest }) => { const toast = useToast(); const ref = useRef<HTMLTextAreaElement>(null); const rows = useMemo(() => { const lines = children.split("\n"); return Math.min(10, Math.max(3, lines.length)); }, [children]); return ( <Box position="relative" {...rest}> <Box asChild p="2"> <Text asChild size="2"> <textarea ref={ref} className={css.textarea} value={children} readOnly rows={rows} style={{ fontFamily: mono ? "var(--code-font-family)" : "var(--default-font-family)", }} /> </Text> </Box> <Tooltip content="クリップボードにコピー"> <Box asChild position="absolute" bottom="2" right="2"> <IconButton size="1" variant="surface" color="gray" onClick={async (event) => { event.preventDefault(); event.stopPropagation(); try { await navigator.clipboard.writeText(children); toast.open({ title: "クリップボードにコピーしました", severity: "info", }); } catch (error) { toast.open({ title: "クリップボードへのコピーに失敗しました", description: String(error), severity: "danger", }); } }} > <CopyIcon /> </IconButton> </Box> </Tooltip> </Box> ); };
-
-
-
@@ -1,5 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./CopyrightNotice/FirstParty.tsx"; export * from "./CopyrightNotice/ThirdParty.tsx";
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { FirstParty } from "./FirstParty.tsx"; export default { component: FirstParty, } satisfies Meta<typeof FirstParty>; export const Defaults: StoryObj<typeof FirstParty> = {};
-
-
-
@@ -1,8 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type FC } from "react"; export const FirstParty: FC = () => { return "© 2024 Shota FUJI"; };
-
-
-
@@ -1,88 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Text } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { ThirdParty, ThirdPartyNoticeProvider } from "./ThirdParty.tsx"; export default { component: ThirdParty, } satisfies Meta<typeof ThirdParty>; type Story = StoryObj<typeof ThirdParty>; export const WithoutProvider: Story = {}; export const TextValue: Story = { decorators: [ (Story) => ( <ThirdPartyNoticeProvider text={"Foo\nBar"}> <Story /> </ThirdPartyNoticeProvider> ), ], }; export const MassiveText: Story = { decorators: [ (Story) => ( <ThirdPartyNoticeProvider text={Array.from({ length: 1_000 }, () => "Text").join("\n")} > <Story /> </ThirdPartyNoticeProvider> ), ], }; export const ReactNode: Story = { decorators: [ (Story) => ( <ThirdPartyNoticeProvider text={ <> <Text as="p">Text</Text> <Button variant="outline" type="button"> Button </Button> </> } > <Story /> </ThirdPartyNoticeProvider> ), ], }; export const SlowLoad: Story = { decorators: [ (Story) => ( <ThirdPartyNoticeProvider text={async () => { await new Promise<void>((resolve) => { setTimeout(() => void resolve(), 5_000); }); return "Foo\nBar"; }} > <Story /> </ThirdPartyNoticeProvider> ), ], }; export const LoadFailure: Story = { decorators: [ (Story) => ( <ThirdPartyNoticeProvider text={() => { throw new Error("Test Error"); }} > <Story /> </ThirdPartyNoticeProvider> ), ], };
-
-
-
@@ -1,102 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Box, Button, Dialog, Flex, Spinner, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { createContext, type FC, type ReactNode, use, useMemo } from "react"; import { ErrorCallout } from "../ErrorCallout.ts"; interface ThirdPartyNoticeContextValue { text(): string | ReactNode | Promise<string | ReactNode>; } const Context = createContext<ThirdPartyNoticeContextValue | null>(null); export const ThirdParty: FC = () => { const ctx = use(Context); if (!ctx) { return null; } return <Provided value={ctx} />; }; export interface ThirdPartyNoticeProviderProps { text: string | ReactNode | ThirdPartyNoticeContextValue["text"]; children: ReactNode; } export const ThirdPartyNoticeProvider: FC<ThirdPartyNoticeProviderProps> = ({ children, text, }) => { const value = useMemo((): ThirdPartyNoticeContextValue => { return { text: typeof text === "function" ? text : () => text, }; }, [text]); return <Context.Provider value={value}>{children}</Context.Provider>; }; interface ProvidedProps { value: ThirdPartyNoticeContextValue; } const Provided: FC<ProvidedProps> = ({ value }: ProvidedProps) => { const query = useQuery({ queryFn() { return value.text(); }, queryKey: ["CopyrightNotice.ThirdParty.text"], }); return ( <Dialog.Root> <Dialog.Trigger> <Button size="1" variant="ghost"> ライセンス </Button> </Dialog.Trigger> <Dialog.Content maxHeight="calc(100dvh - var(--space-6) * 2)"> <Dialog.Title>ライセンス</Dialog.Title> <Dialog.Description mb="3"> このアプリケーションでは以下のソフトウェアを利用しています。 </Dialog.Description> <Box style={{ whiteSpace: "pre-wrap" }}> {query.isError ? ( <ErrorCallout title="読み込みに失敗しました" severity="danger" actions={ <Button size="1" onClick={() => void query.refetch()}> 再試行 </Button> } > {query.error instanceof Error ? query.error.message : String(query.error)} </ErrorCallout> ) : query.isLoading ? ( <Flex gap="2" align="center"> <Spinner /> <Text color="gray" size="2"> 読込中 </Text> </Flex> ) : ( query.data )} </Box> <Flex position="sticky" bottom="0" justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,52 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import * as Empty from "./Empty.ts"; export default { component: Empty.Root, args: { children: ( <> <Empty.Title>Title</Empty.Title> <Empty.Description>Description Text.</Empty.Description> <Empty.Actions> <Button>Foo</Button> <Button variant="outline">Bar</Button> </Empty.Actions> </> ), }, } satisfies Meta<(typeof Empty)["Root"]>; type Story = StoryObj<(typeof Empty)["Root"]>; export const Defaults: Story = {}; export const WithoutActions: Story = { args: { children: ( <> <Empty.Title>Title</Empty.Title> <Empty.Description>Description Text.</Empty.Description> </> ), }, }; export const WithoutDescription: Story = { args: { children: ( <> <Empty.Title>Title</Empty.Title> <Empty.Actions> <Button>Foo</Button> <Button variant="outline">Bar</Button> </Empty.Actions> </> ), }, };
-
-
-
@@ -1,7 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./Empty/Root.tsx"; export * from "./Empty/Title.tsx"; export * from "./Empty/Actions.tsx"; export * from "./Empty/Description.tsx";
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex } from "@radix-ui/themes"; import { type FC, type ReactElement } from "react"; export interface ActionsProps { children: ReactElement | ReactElement[]; } export const Actions: FC<ActionsProps> = ({ children }) => { return ( <Flex direction="column" display="inline-flex" minWidth="8rem" mt="4" gap="2"> {children} </Flex> ); };
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Text } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface DescriptionProps { children: ReactNode; } export const Description: FC<DescriptionProps> = ({ children }) => { return ( <Text as="p" size="2" align="center"> {children} </Text> ); };
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; interface RootProps { children: ReactNode; } export const Root: FC<RootProps> = ({ children }) => { return ( <Flex mt="7" direction="column" align="center" gap="3"> {children} </Flex> ); };
-
-
-
@@ -1,11 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Heading, type HeadingProps } from "@radix-ui/themes"; import { type FC } from "react"; export type TitleProps = Omit<HeadingProps, "size">; export const Title: FC<TitleProps> = ({ as = "h2", ...rest }) => { return <Heading as={as} {...rest} size="4" />; };
-
-
-
@@ -1,8 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export { ErrorCallout } from "./ErrorCallout/ErrorCallout.tsx"; export type { ErrorCalloutProps } from "./ErrorCallout/ErrorCallout.tsx"; export { Managed as ManagedErrorCallout } from "./ErrorCallout/Managed.tsx"; export type { ManagedProps as ManagedErrorCalloutProps } from "./ErrorCallout/Managed.tsx";
-
-
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { ErrorCallout } from "./ErrorCallout.tsx"; export default { component: ErrorCallout, args: { title: "タイトル", children: "説明", actions: <Button size="1">アクション</Button>, }, } satisfies Meta<typeof ErrorCallout>; type Story = StoryObj<typeof ErrorCallout>; export const Defaults: Story = {}; export const Warning: Story = { args: { severity: "warning", }, }; export const TitleOnly: Story = { args: { children: null, actions: null, }, };
-
-
-
@@ -1,56 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { Callout, Flex, Text } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface ErrorCalloutProps extends Pick< Callout.RootProps, "variant" | "size" | "highContrast" | "style" | "role" > { className?: string | undefined; severity?: "danger" | "warning"; title: ReactNode; children?: ReactNode; actions?: ReactNode; } export const ErrorCallout: FC<ErrorCalloutProps> = ({ className, severity = "danger", title, children, actions, ...rest }) => { return ( <Callout.Root {...rest} className={className} color={severity === "danger" ? "red" : "amber"} > <Callout.Icon> <ExclamationTriangleIcon /> </Callout.Icon> <Flex asChild direction="column" gap="3" width="100%"> <Callout.Text> <Flex as="span" direction="column" gap="1"> <Text weight="bold">{title}</Text> {children && <Text>{children}</Text>} </Flex> {actions && ( <Flex as="span" justify="start" gap="2"> {actions} </Flex> )} </Callout.Text> </Flex> </Callout.Root> ); };
-
-
-
@@ -1,61 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type FC } from "react"; import { type ErrorCalloutProps } from "./ErrorCallout.tsx"; import * as CapabilityError from "./errors/CapabilityError.tsx"; import * as SystemError from "./errors/SystemError.tsx"; import * as IllegalMessageError from "./errors/IllegalMessageError.tsx"; import * as GenericError from "./errors/GenericError.tsx"; import * as UnhandledMessageError from "./errors/UnhandledMessageError.tsx"; import * as NonErrorThrownError from "./errors/NonErrorThrownError.tsx"; import * as NotFoundError from "./errors/NotFoundError.tsx"; import * as NoStorageSpaceError from "./errors/NoStorageSpaceError.tsx"; import * as MissingFieldError from "./errors/MissingFieldError.tsx"; import * as UserInputError from "./errors/UserInputError.tsx"; export interface ManagedProps extends ErrorCalloutProps { error: unknown; } export const Managed: FC<ManagedProps> = ({ error, ...rest }) => { if (CapabilityError.is(error)) { return <CapabilityError.Callout error={error} {...rest} />; } if (SystemError.is(error)) { return <SystemError.Callout error={error} {...rest} />; } if (NoStorageSpaceError.is(error)) { return <NoStorageSpaceError.Callout error={error} {...rest} />; } if (MissingFieldError.is(error)) { return <MissingFieldError.Callout error={error} {...rest} />; } if (NotFoundError.is(error)) { return <NotFoundError.Callout error={error} {...rest} />; } if (UnhandledMessageError.is(error)) { return <UnhandledMessageError.Callout error={error} {...rest} />; } if (IllegalMessageError.is(error)) { return <IllegalMessageError.Callout error={error} {...rest} />; } if (UserInputError.is(error)) { return <UserInputError.Callout error={error} {...rest} />; } if (GenericError.is(error)) { return <GenericError.Callout error={error} {...rest} />; } return <NonErrorThrownError.Callout error={error} {...rest} />; };
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { CapabilityErrorSchema } from "@yamori/proto/yamori/error/v1/capability_error_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./CapabilityError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(CapabilityErrorSchema, { path: "foo_bar_key", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const EmptyPath: Story = { args: { error: create(CapabilityErrorSchema, {}), }, };
-
-
-
@@ -1,73 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, DataList, Dialog, Flex, Text } from "@radix-ui/themes"; import { CapabilityErrorSchema, type CapabilityError, } from "@yamori/proto/yamori/error/v1/capability_error_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is CapabilityError { return isMessage(x, CapabilityErrorSchema); } export interface CalloutProps extends ErrorCalloutProps { error: CapabilityError; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || "この操作を行う権限がありません。"} </ErrorCallout> <Dialog.Content> <Dialog.Title>操作権限エラー</Dialog.Title> <Dialog.Description size="2"> 処理を行うために必要な権限がない、もしくはこのプラットフォーム上で処理がサポートされていない際のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> この操作を行う権限があることを確認してください。 権限があるにも関わらずこのエラーが発生する場合はアプリケーション不具合の可能性があります。 </Text> <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>権限キー名</DataList.Label> <DataList.Value> {error.path ? <Code>{error.path}</Code> : "---"} </DataList.Value> </DataList.Item> </DataList.Root> <Text as="p" color="gray" size="1" mt="5"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./GenericError.tsx"; export default { component: Callout, args: { title: "エラー", error: new Error("Foo Bar"), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {};
-
-
-
@@ -1,69 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Dialog, Heading, Flex, Text } from "@radix-ui/themes"; import { type FC } from "react"; import { CopyableText } from "../../CopyableText.tsx"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is Error { return x instanceof Error; } export interface CalloutProps extends ErrorCalloutProps { error: Error; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || "アプリケーションエラーが発生しました"} </ErrorCallout> <Dialog.Content> <Dialog.Title>アプリケーションエラー</Dialog.Title> <Dialog.Description size="2"> アプリケーションコードの実行中に予期せぬエラーが発生し、処理が中断されました。 </Dialog.Description> <Text as="p" size="2" mt="2"> ブラウザやアプリケーションの不具合、古いブラウザを利用している、ブラウザの拡張機能がアプリケーションの動作に対して影響を与えている、といった可能性が考えられます。 また、ブラウザの設定で一部機能が制限されていたりネットワークの状況が悪い場合もこのエラーが起こることがあります。 </Text> <Heading as="h2" size="3" mt="5"> エラー内容 </Heading> <CopyableText mono mt="3" mb="1"> {error.message + (error.stack ? "\n" + error.stack : "")} </CopyableText> <Text as="p" color="gray" size="1" mt="3"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { Callout } from "./IllegalMessageError.tsx"; export default { component: Callout, args: { title: "エラー", error: new IllegalMessageError({ foo: "bar" }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {};
-
-
-
@@ -1,70 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Dialog, Heading, Flex, Text } from "@radix-ui/themes"; import { type FC } from "react"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { CopyableText } from "../../CopyableText.tsx"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is IllegalMessageError { return x instanceof IllegalMessageError; } export interface CalloutProps extends ErrorCalloutProps { error: IllegalMessageError; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || "処理結果を読み込めませんでした"} </ErrorCallout> <Dialog.Content> <Dialog.Title>不正処理電文エラー</Dialog.Title> <Dialog.Description size="2"> 処理結果を受信しましたが、結果データ内に必須な項目が抜けているなどして表示が行えない場合のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> アプリケーションの不具合や想定されていないデータが入力されたなどの原因が考えられます。 入力内容を変えて再試行すると解消する場合があります。 </Text> <Heading as="h2" size="3" mt="5"> 受信電文 </Heading> <CopyableText mono mt="3"> {JSON.stringify(error.data, null, 2)} </CopyableText> <Text as="p" color="gray" size="1" mt="3"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { MissingFieldErrorSchema } from "@yamori/proto/yamori/error/v1/missing_field_error_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./MissingFieldError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(MissingFieldErrorSchema, { path: "foo.bar", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const NoPath: Story = { args: { error: create(MissingFieldErrorSchema, {}), }, };
-
-
-
@@ -1,76 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, DataList, Dialog, Flex, Text } from "@radix-ui/themes"; import { MissingFieldErrorSchema, type MissingFieldError, } from "@yamori/proto/yamori/error/v1/missing_field_error_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is MissingFieldError { return isMessage(x, MissingFieldErrorSchema); } export interface CalloutProps extends ErrorCalloutProps { error: MissingFieldError; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || (error.path ? `${error.path} が未指定です` : "必須フィールドが未指定です")} </ErrorCallout> <Dialog.Content> <Dialog.Title>必須フィールド未指定エラー</Dialog.Title> <Dialog.Description size="2"> 処理に必須な項目が未指定、もしくは空値の場合のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> アプリケーション上ではこのエラーが起こらないようにチェック処理を行っています。 そのため、このエラーが表示される場合は特定のデータにおけるアプリケーションの不具合の可能性が高いです。 </Text> <Text as="p" size="2" mt="2"> 入力項目がある場合は値を変えて再試行すると解消される場合があります。 </Text> {error.path && ( <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>フィールド</DataList.Label> <DataList.Value> <Code>{error.path}</Code> </DataList.Value> </DataList.Item> </DataList.Root> )} <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,28 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { NoStorageSpaceSchema } from "@yamori/proto/yamori/error/v1/no_storage_space_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./NoStorageSpaceError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(NoStorageSpaceSchema, { message: "Storage is full.", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const NoMessage: Story = { args: { error: create(NoStorageSpaceSchema, {}), }, };
-
-
-
@@ -1,68 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage } from "@bufbuild/protobuf"; import { Button, DataList, Dialog, Flex, Text } from "@radix-ui/themes"; import { NoStorageSpaceSchema, type NoStorageSpace, } from "@yamori/proto/yamori/error/v1/no_storage_space_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is NoStorageSpace { return isMessage(x, NoStorageSpaceSchema); } export interface CalloutProps extends ErrorCalloutProps { error: NoStorageSpace; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || "保存領域がありません"} </ErrorCallout> <Dialog.Content> <Dialog.Title>保存領域エラー</Dialog.Title> <Dialog.Description size="2"> データの追加・更新を行おうとした際に保存領域が足りず、処理が中断された際のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> 既存のデータを削除することで解消される場合があります。 </Text> {error.message && ( <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>メッセージ</DataList.Label> <DataList.Value>{error.message}</DataList.Value> </DataList.Item> </DataList.Root> )} <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,40 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./NonErrorThrownError.tsx"; export default { component: Callout, args: { title: "エラー", }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const String: Story = { args: { error: "String", }, }; export const Number: Story = { args: { error: 1, }, }; export const Boolean: Story = { args: { error: true, }, }; export const Symbol_: Story = { name: "Symbol", args: { error: Symbol(), }, };
-
-
-
@@ -1,68 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Dialog, Heading, Flex, Text } from "@radix-ui/themes"; import { type FC } from "react"; import { CopyableText } from "../../CopyableText.tsx"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export interface CalloutProps extends ErrorCalloutProps { error: unknown; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || "予期せぬエラーが発生しました"} </ErrorCallout> <Dialog.Content> <Dialog.Title>異常実行例外</Dialog.Title> <Dialog.Description size="2"> 何かしらの異常によって処理が中断されましたが、補足されたエラーデータが不正なため詳細を表示できない場合のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> ブラウザの拡張機能がアプリケーションの動作に対して影響を与えている、ネットワークプロキシや不正なネットワーク設定により本来とは異なるアプリケーションコードが読み込まれている、アプリケーションの不具合、といった可能性が考えられます。 </Text> <Heading as="h2" size="3" mt="5"> エラー内容 </Heading> <CopyableText mono mt="3" mb="1"> {typeof error === "string" || typeof error === "boolean" ? JSON.stringify(error) : typeof error === "number" ? error.toString(10) : `<${typeof error}>`} </CopyableText> <Text as="p" color="gray" size="1" mt="3"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,44 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./NotFoundError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(NotFoundSchema, { typeName: "Foo", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const Workspace: Story = { args: { error: create(NotFoundSchema, { typeName: "yamori.workspace.v1.Workspace", }), }, }; export const Worker: Story = { args: { error: create(NotFoundSchema, { typeName: "yamori.worker.v1.Worker", }), }, }; export const EmptyTypeName: Story = { args: { error: create(NotFoundSchema, {}), }, };
-
-
-
@@ -1,84 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, DataList, Dialog, Flex, Text } from "@radix-ui/themes"; import { NotFoundSchema, type NotFound, } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is NotFound { return isMessage(x, NotFoundSchema); } function message(typeName: string): string { switch (typeName) { case "yamori.workspace.v1.Workspace": return "対象のワークスペースが見つかりません。"; case "yamori.worker.v1.Worker": return "対象の労働者が見つかりません。"; default: return "対象のデータが見つかりません。"; } } export interface CalloutProps extends ErrorCalloutProps { error: NotFound; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || message(error.typeName)} </ErrorCallout> <Dialog.Content> <Dialog.Title>該当データなしエラー</Dialog.Title> <Dialog.Description size="2"> 処理を行う直接・間接の対象となるデータが存在せず処理が中断された際のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> 対象のデータが削除されていないか確認してください。 データが存在するにも関わらずこのエラーが発生する場合はアプリケーション不具合の可能性があります。 </Text> <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>データ種別</DataList.Label> <DataList.Value> {error.typeName ? <Code>{error.typeName}</Code> : "---"} </DataList.Value> </DataList.Item> </DataList.Root> <Text as="p" color="gray" size="1" mt="5"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { SystemErrorSchema } from "@yamori/proto/yamori/error/v1/system_error_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./SystemError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(SystemErrorSchema, { code: "SAMPLE_ERR", message: "This is sample error message.", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {}; export const NoDetails: Story = { args: { error: create(SystemErrorSchema, {}), }, };
-
-
-
@@ -1,81 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, DataList, Dialog, Flex, Text } from "@radix-ui/themes"; import { SystemErrorSchema, type SystemError, } from "@yamori/proto/yamori/error/v1/system_error_pb.js"; import { type FC } from "react"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is SystemError { return isMessage(x, SystemErrorSchema); } export interface CalloutProps extends ErrorCalloutProps { error: SystemError; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || ( <> システムエラーが発生しました{error.code ? ` (コード: ${error.code})` : null} </> )} </ErrorCallout> <Dialog.Content> <Dialog.Title>システムエラー</Dialog.Title> <Dialog.Description size="2"> 処理中に予期せぬエラーが発生し、処理が中断された際のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> アプリケーションの不具合やネットワーク障害、データが不正など様々な原因が考えられます。 原因が一時的な場合は再試行することで解消することがあります。 </Text> <DataList.Root mt="5" orientation={{ initial: "vertical", sm: "horizontal" }}> <DataList.Item> <DataList.Label>コード</DataList.Label> <DataList.Value> {error.code ? <Code>{error.code}</Code> : "---"} </DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>メッセージ</DataList.Label> <DataList.Value>{error.message || "---"}</DataList.Value> </DataList.Item> </DataList.Root> <Text as="p" color="gray" size="1" mt="5"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,25 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { Callout } from "./UnhandledMessageError.tsx"; export default { component: Callout, args: { title: "エラー", error: create(WorkspaceSchema, { id: { value: "ws-foo", }, displayName: "Foo", }), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {};
-
-
-
@@ -1,69 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Message, isMessage } from "@bufbuild/protobuf"; import { Button, Dialog, Flex, Heading, Text } from "@radix-ui/themes"; import { type FC } from "react"; import { CopyableText } from "../../CopyableText.tsx"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is Message { return isMessage(x); } export interface CalloutProps extends ErrorCalloutProps { error: Message; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <Dialog.Root> <ErrorCallout {...rest} actions={ <> {actions} <Dialog.Trigger> <Button size="1" variant="outline"> 詳細 </Button> </Dialog.Trigger> </> } > {children || "未定義のエラーが発生しました"} </ErrorCallout> <Dialog.Content> <Dialog.Title>未定義メッセージエラー</Dialog.Title> <Dialog.Description size="2"> 処理エラーを受信しましたが、エラー内容が事前に定義された内容と異なるため詳細が表示できない場合のエラーです。 </Dialog.Description> <Text as="p" size="2" mt="2"> アプリケーションの不具合となるため問い合わせてください。 </Text> <Heading as="h2" size="3" mt="5"> 受信メッセージ </Heading> <CopyableText mono mt="3"> {JSON.stringify(error, null, 2)} </CopyableText> <Text as="p" color="gray" size="1" mt="3"> 上記は問い合わせの際に必要な情報となります。 </Text> <Flex justify="end" mt="5"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> </Dialog.Root> ); };
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { UserInputError } from "../../../errors/UserInputError.ts"; import { Callout } from "./UserInputError.tsx"; export default { component: Callout, args: { title: "エラー", error: new UserInputError("Foo Bar"), }, } satisfies Meta<typeof Callout>; type Story = StoryObj<typeof Callout>; export const Defaults: Story = {};
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type FC } from "react"; import { UserInputError } from "../../../errors/UserInputError.ts"; import { ErrorCallout, type ErrorCalloutProps } from "../ErrorCallout.tsx"; export function is(x: unknown): x is UserInputError { return x instanceof UserInputError; } export interface CalloutProps extends ErrorCalloutProps { error: UserInputError; } export const Callout: FC<CalloutProps> = ({ error, actions, children, ...rest }) => { return ( <ErrorCallout {...rest} actions={actions}> {children || error.message || "処理できない入力内容です"} </ErrorCallout> ); };
-
-
-
@@ -1,6 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./FormField/Root.tsx"; export * from "./FormField/Label.tsx"; export * from "./FormField/Description.tsx";
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Text, type TextProps } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface DescriptionProps extends Omit<TextProps, "as" | "size"> { /** * エラーとして表示するメッセージ。 * このプロパティが表示される場合はこちらを優先して表示し、見た目も * エラーとわかりやすい状態になる。 */ error?: ReactNode; } export const Description: FC<DescriptionProps> = ({ error, children, color = "gray", ...rest }) => { return ( <Text {...rest} as="span" size="1" color={error ? "red" : color}> {error || children} </Text> ); };
-
-
-
@@ -1,18 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Text, type TextProps } from "@radix-ui/themes"; import { type FC } from "react"; export type LabelProps = Omit< Extract<TextProps, { as: "label" }>, "as" | "size" | "weight" | "asChild" >; export const Label: FC<LabelProps> = ({ children, ...rest }) => { return ( <Text {...rest} as="label" size="1" weight="bold"> {children} </Text> ); };
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex, type FlexProps } from "@radix-ui/themes"; import { type FC } from "react"; export type RootProps = Omit<FlexProps, "direction" | "gap">; export const Root: FC<RootProps> = ({ children, ...rest }) => { return ( <Flex {...rest} direction="column" gap="1"> {children} </Flex> ); };
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import * as HelpDialog from "./HelpDialog.ts"; export default { component: HelpDialog.Root, args: { children: ( <> <HelpDialog.Trigger>なにかの説明</HelpDialog.Trigger> <HelpDialog.Content> <HelpDialog.Title>なにかの説明</HelpDialog.Title> <HelpDialog.Description>メインな説明</HelpDialog.Description> <HelpDialog.Paragraph>お供な説明</HelpDialog.Paragraph> </HelpDialog.Content> </> ), }, } satisfies Meta<(typeof HelpDialog)["Root"]>; type Story = StoryObj<(typeof HelpDialog)["Root"]>; export const Defaults: Story = {};
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Dialog } from "@radix-ui/themes"; export * from "./HelpDialog/Content.tsx"; export * from "./HelpDialog/Trigger.tsx"; export * from "./HelpDialog/Paragraph.tsx"; export const Root = Dialog.Root; export type RootProps = Dialog.RootProps; export const Title = Dialog.Title; export type TitleProps = Dialog.TitleProps; export const Description = Dialog.Description; export type DescriptionProps = Dialog.DescriptionProps;
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Dialog, Flex } from "@radix-ui/themes"; import { type FC } from "react"; export type ContentProps = Dialog.ContentProps; export const Content: FC<ContentProps> = ({ children, ...rest }) => { return ( <Dialog.Content {...rest}> {children} <Flex mt="5" align="center" justify="end"> <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> </Flex> </Dialog.Content> ); };
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Text, type TextProps } from "@radix-ui/themes"; import { type FC } from "react"; export type ParagraphProps = Omit< Extract<TextProps, { as: "p" }>, "as" | "asChild" | "mt" >; export const Paragraph: FC<ParagraphProps> = (props) => { return <Text {...props} as="p" mt="2" />; };
-
-
-
@@ -1,26 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; import { IconButton, Dialog, Tooltip, VisuallyHidden } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface TriggerProps extends Omit<Dialog.TriggerProps, "children"> { /** * ツールチップやスクリーンリーダー向けに表示されるテキスト。 */ children: ReactNode; } export const Trigger: FC<TriggerProps> = ({ children, ...rest }) => { return ( <Tooltip content={children} delayDuration={700}> <Dialog.Trigger {...rest}> <IconButton variant="ghost" size="1" color="gray"> <QuestionMarkCircledIcon /> <VisuallyHidden>{children}</VisuallyHidden> </IconButton> </Dialog.Trigger> </Tooltip> ); };
-
-
-
@@ -1,9 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ :where(.logo) { height: 1em; width: auto; }
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { Logo } from "./Logo.tsx"; export default { component: Logo, } satisfies Meta<typeof Logo>; type Story = StoryObj<typeof Logo>; export const Defaults: Story = {};
-
-
-
@@ -1,36 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type CSSProperties, type FC } from "react"; import css from "./Logo.module.css"; export interface LogoProps { className?: string; style?: CSSProperties; } export const Logo: FC<LogoProps> = ({ className = "", ...rest }) => { return ( <svg {...rest} className={`${css.logo} ${className}`} xmlns="http://www.w3.org/2000/svg" fill="none" role="img" aria-label="Yamori" height="1em" viewBox="0 0 232 71" > <path fill="currentColor" fillOpacity=".8" d="M231.073 19.281V70h-7.078V19.281h7.078ZM223.432 5.36c0-1.28.344-2.359 1.032-3.234.718-.875 1.765-1.313 3.14-1.313 1.375 0 2.422.438 3.141 1.313.719.875 1.078 1.953 1.078 3.234 0 1.25-.359 2.313-1.078 3.188-.719.844-1.766 1.265-3.141 1.265s-2.422-.421-3.14-1.265c-.688-.875-1.032-1.938-1.032-3.188ZM205.148 27.39V70h-7.079V19.281h6.844l.235 8.11Zm13.453-8.437v6.938a12.697 12.697 0 0 0-1.735-.235 15.824 15.824 0 0 0-1.828-.093c-1.719 0-3.234.359-4.547 1.078-1.281.718-2.375 1.734-3.281 3.047-.906 1.312-1.625 2.843-2.156 4.593-.5 1.75-.828 3.657-.985 5.719l-2.015 1.172c0-3.156.25-6.11.75-8.86.531-2.75 1.328-5.171 2.39-7.265 1.094-2.094 2.485-3.735 4.172-4.922 1.688-1.188 3.703-1.781 6.047-1.781.5 0 1.094.062 1.781.187.688.125 1.157.266 1.407.422ZM153.229 47.172v-5.016c0-3.906.484-7.343 1.453-10.312.969-2.969 2.312-5.453 4.031-7.453 1.719-2 3.734-3.5 6.047-4.5 2.344-1.032 4.875-1.547 7.594-1.547 2.812 0 5.375.515 7.687 1.547 2.344 1 4.375 2.5 6.094 4.5 1.75 2 3.094 4.484 4.031 7.453.969 2.968 1.453 6.406 1.453 10.312v5.016c0 3.906-.484 7.344-1.453 10.312-.937 2.938-2.281 5.422-4.031 7.453-1.719 2-3.75 3.5-6.094 4.5-2.312 1-4.844 1.5-7.594 1.5s-5.296-.5-7.64-1.5c-2.313-1-4.344-2.5-6.094-4.5-1.719-2.03-3.062-4.515-4.031-7.453-.969-2.968-1.453-6.406-1.453-10.312Zm7.078-5.016v5.016c0 2.812.281 5.312.844 7.5.593 2.187 1.421 4.031 2.484 5.531 1.094 1.5 2.375 2.64 3.844 3.422 1.5.75 3.156 1.125 4.968 1.125 2.032 0 3.797-.375 5.297-1.125 1.532-.781 2.797-1.922 3.797-3.422 1.031-1.5 1.797-3.344 2.297-5.531.5-2.188.75-4.688.75-7.5v-5.016c0-2.812-.297-5.297-.891-7.453-.562-2.187-1.39-4.031-2.484-5.531s-2.391-2.64-3.891-3.422c-1.468-.781-3.125-1.172-4.968-1.172-1.782 0-3.422.39-4.922 1.172-1.469.781-2.735 1.922-3.797 3.422-1.063 1.5-1.891 3.344-2.484 5.531-.563 2.156-.844 4.64-.844 7.453ZM95.145 29.5V70h-7.031V19.281h6.656l.375 10.219Zm-1.406 12.75-3.282-.656c.032-3.282.422-6.328 1.172-9.14.75-2.813 1.844-5.282 3.282-7.407 1.437-2.125 3.234-3.766 5.39-4.922 2.156-1.188 4.641-1.781 7.453-1.781 1.969 0 3.782.343 5.438 1.031 1.687.656 3.14 1.703 4.359 3.14 1.25 1.438 2.219 3.282 2.906 5.532.688 2.25 1.032 4.969 1.032 8.156V70h-7.032V36.578c0-2.937-.39-5.266-1.171-6.984-.782-1.719-1.86-2.953-3.235-3.703-1.375-.782-3-1.172-4.875-1.172-2.156 0-3.969.5-5.437 1.5-1.469 1-2.64 2.36-3.516 4.078-.844 1.687-1.469 3.578-1.875 5.672a38.196 38.196 0 0 0-.61 6.281Zm27.656-4.64-4.688 1.124c.032-2.656.422-5.203 1.172-7.64.75-2.438 1.844-4.61 3.282-6.516a16.26 16.26 0 0 1 5.25-4.547c2.093-1.125 4.484-1.687 7.171-1.687 2.313 0 4.344.36 6.094 1.078a11.086 11.086 0 0 1 4.453 3.328c1.25 1.469 2.188 3.375 2.813 5.719.625 2.312.937 5.078.937 8.297V70h-7.078V36.672c0-3.125-.39-5.547-1.172-7.266-.75-1.718-1.812-2.922-3.187-3.61-1.375-.718-3-1.077-4.875-1.077-1.656 0-3.11.375-4.36 1.125-1.25.718-2.296 1.703-3.14 2.953-.844 1.219-1.5 2.594-1.969 4.125a18.307 18.307 0 0 0-.703 4.687Z" /> <path fill="currentColor" d="M68.914 58.797V34.75c0-1.625-.203-2.906-.61-3.844-.375-.968-.953-1.672-1.734-2.11-.781-.437-1.75-.655-2.906-.655-1.219 0-2.25.25-3.094.75-.813.5-1.422 1.218-1.828 2.156-.406.906-.61 2-.61 3.281H45.712c0-2.156.422-4.187 1.265-6.094a15.072 15.072 0 0 1 3.657-5.109c1.624-1.5 3.562-2.672 5.812-3.516 2.281-.843 4.813-1.265 7.594-1.265 3.343 0 6.312.562 8.906 1.687 2.594 1.125 4.64 2.907 6.14 5.344 1.5 2.438 2.25 5.61 2.25 9.516v22.968c0 2.688.141 4.938.422 6.75.313 1.782.766 3.313 1.36 4.594V70H70.6c-.562-1.344-.984-3.031-1.265-5.063a45.412 45.412 0 0 1-.422-6.14Zm1.547-19.781.046 7.64H65.54c-1.375 0-2.578.203-3.61.61-1.03.406-1.906.984-2.625 1.734a7.328 7.328 0 0 0-1.547 2.672c-.343 1-.515 2.078-.515 3.234 0 1.344.187 2.469.562 3.375.375.907.938 1.594 1.688 2.063.75.437 1.656.656 2.719.656 1.562 0 2.906-.328 4.03-.984 1.157-.657 2.016-1.438 2.579-2.344.594-.938.797-1.813.61-2.625l2.812 4.922c-.313 1.187-.797 2.422-1.453 3.703a17.853 17.853 0 0 1-2.485 3.562c-1 1.094-2.218 1.985-3.656 2.672-1.437.688-3.14 1.031-5.11 1.031-2.75 0-5.25-.609-7.5-1.828-2.218-1.25-3.984-2.984-5.296-5.203-1.281-2.25-1.922-4.922-1.922-8.015 0-2.563.422-4.875 1.266-6.938.843-2.094 2.078-3.86 3.703-5.297 1.656-1.468 3.75-2.61 6.281-3.422 2.531-.812 5.5-1.218 8.906-1.218h5.485ZM14.953 1.75l10.969 31.313L36.797 1.75h14.156L32.391 45.11V70H19.359V45.11L.844 1.75h14.11Z" /> </svg> ); };
-
-
-
@@ -1,38 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import * as NavigationMenu from "./NavigationMenu.ts"; export default { component: NavigationMenu.Root, args: { children: ( <> <NavigationMenu.Group title="Foo"> <NavigationMenu.Item> <a href="/link">Link</a> </NavigationMenu.Item> </NavigationMenu.Group> <NavigationMenu.Group title="Bar"> <NavigationMenu.Item> <button>Button</button> </NavigationMenu.Item> </NavigationMenu.Group> <NavigationMenu.Separator /> <NavigationMenu.Group title="Root"> <NavigationMenu.Group title="Nested"> <NavigationMenu.Item current> <button>Item</button> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Group> </> ), }, } satisfies Meta<(typeof NavigationMenu)["Root"]>; type Story = StoryObj<(typeof NavigationMenu)["Root"]>; export const Defaults: Story = {};
-
-
-
@@ -1,7 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./NavigationMenu/Root.tsx"; export * from "./NavigationMenu/Group.tsx"; export * from "./NavigationMenu/Item.tsx"; export * from "./NavigationMenu/Separator.tsx";
-
-
-
@@ -1,15 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .caret { transition: transform 0.1s ease-out; } [data-state="closed"] > .caret { transform: rotate(-90deg); } .content[data-state="closed"] { display: none; }
-
-
-
@@ -1,65 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as Collapsible from "@radix-ui/react-collapsible"; import { CaretDownIcon } from "@radix-ui/react-icons"; import { type BoxProps, Flex, Reset, Text } from "@radix-ui/themes"; import { type ContextType, type FC, type ReactNode, use, useCallback, useMemo, useState, } from "react"; import { GroupOpenContext } from "./GroupOpenContext.ts"; import css from "./Group.module.css"; import itemCss from "./Item.module.css"; export interface GroupProps extends Pick<BoxProps, "className" | "children" | "style"> { defaultOpen?: boolean; title: ReactNode; } export const Group: FC<GroupProps> = ({ defaultOpen, children, title, ...rest }) => { const ctx = use(GroupOpenContext); const [isOpen, setIsOpen] = useState(() => defaultOpen ?? false); const open = useCallback(() => { setIsOpen(true); ctx?.open(); }, [ctx?.open]); const ctxValue = useMemo<ContextType<typeof GroupOpenContext>>(() => { return { open }; }, [open]); return ( <Flex {...rest} asChild direction="column" gap="1"> <Collapsible.Root open={isOpen} onOpenChange={setIsOpen}> <Flex asChild className={itemCss.item} p="2" justify="between" align="center"> <Reset> <Collapsible.Trigger> <Text size="2" weight="bold"> {title} </Text> <CaretDownIcon className={css.caret} /> </Collapsible.Trigger> </Reset> </Flex> <GroupOpenContext.Provider value={ctxValue}> <Collapsible.Content asChild forceMount> <Flex className={css.content} direction="column" pl="3" gap="1"> {children} </Flex> </Collapsible.Content> </GroupOpenContext.Provider> </Collapsible.Root> </Flex> ); };
-
-
-
@@ -1,8 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createContext } from "react"; export const GroupOpenContext = createContext<{ open(): void; } | null>(null);
-
-
-
@@ -1,28 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .item { border: 1px solid transparent; border-radius: var(--radius-4); color: var(--gray-11); } .item:hover { background-color: var(--gray-3); color: var(--gray-12); } @media (hover: hover) { .item:focus-visible { outline: 2px solid var(--focus-8); outline-offset: -1px; } } .item[aria-current="true"] { background-color: var(--accent-2); border-color: var(--accent-8); color: var(--accent-11); }
-
-
-
@@ -1,43 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type BoxProps, Box, Reset, Text } from "@radix-ui/themes"; import { type FC, type ReactElement, use, useEffect } from "react"; import { GroupOpenContext } from "./GroupOpenContext.ts"; import css from "./Item.module.css"; export interface ItemProps extends Pick<BoxProps, "className" | "style"> { /** * プラットフォームによってリンクなのかボタンなのか、など変わってくるため * 要素を受けるようにしている。 */ children: ReactElement; current?: boolean; } export const Item: FC<ItemProps> = ({ children, className = "", current, ...rest }) => { const ctx = use(GroupOpenContext); useEffect(() => { if (current) { ctx?.open(); } }, [current, ctx?.open]); return ( <Box {...rest} asChild className={`${css.item} ${className}`} p="2" aria-current={current} > <Text asChild size="2"> <Reset>{children}</Reset> </Text> </Box> ); };
-
-
-
@@ -1,15 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type BoxProps, Flex } from "@radix-ui/themes"; import { type FC } from "react"; export type RootProps = Pick<BoxProps, "className" | "children" | "style">; export const Root: FC<RootProps> = ({ children, ...rest }) => { return ( <Flex {...rest} direction="column" gap="1"> {children} </Flex> ); };
-
-
-
@@ -1,14 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Separator as RadixSeparator, type SeparatorProps as RadixSeparatorProps, } from "@radix-ui/themes"; import { type FC } from "react"; export type SeparatorProps = Omit<RadixSeparatorProps, "my" | "mt" | "mb" | "size">; export const Separator: FC<SeparatorProps> = (props) => { return <RadixSeparator {...props} size="4" my="2" />; };
-
-
-
@@ -1,30 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { PaidLeaveProvisionTableSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_pb.js"; import { PaidLeaveProvisionTableView } from "./PaidLeaveProvisionTableView.tsx"; export default { component: PaidLeaveProvisionTableView, args: { paidLeaveProvisionTable: proto.create(PaidLeaveProvisionTableSchema), }, } satisfies Meta<typeof PaidLeaveProvisionTableView>; type Story = StoryObj<typeof PaidLeaveProvisionTableView>; export const Empty: Story = {}; export const Filled: Story = { args: { paidLeaveProvisionTable: proto.create(PaidLeaveProvisionTableSchema, { currentRevision: { firstProvisionAmountDays: 10, subsequentProvisionAmountDays: [11, 12, 14, 16, 18], }, }), }, };
-
-
-
@@ -1,51 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Table } from "@radix-ui/themes"; import { type PaidLeaveProvisionTableSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_pb.js"; import { type FC } from "react"; export interface PaidLeaveProvisionTableViewProps extends Omit<Table.RootProps, "children"> { paidLeaveProvisionTable: proto.MessageShape<typeof PaidLeaveProvisionTableSchema>; } export const PaidLeaveProvisionTableView: FC<PaidLeaveProvisionTableViewProps> = ({ paidLeaveProvisionTable, ...rest }) => { const { currentRevision } = paidLeaveProvisionTable; return ( <Table.Root {...rest}> <Table.Header> <Table.Row> <Table.ColumnHeaderCell>付与回</Table.ColumnHeaderCell> <Table.ColumnHeaderCell>付与日数</Table.ColumnHeaderCell> </Table.Row> </Table.Header> <Table.Body> {currentRevision && ( <> <Table.Row> <Table.RowHeaderCell>初回</Table.RowHeaderCell> <Table.Cell>{currentRevision.firstProvisionAmountDays}日</Table.Cell> </Table.Row> {currentRevision.subsequentProvisionAmountDays.map((days, offset, list) => { return ( <Table.Row key={offset}> <Table.RowHeaderCell> {offset + 2}回目{offset === list.length - 1 && "~"} </Table.RowHeaderCell> <Table.Cell>{days}日</Table.Cell> </Table.Row> ); })} </> )} </Table.Body> </Table.Root> ); };
-
-
-
@@ -1,70 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Badge, type BadgeProps, Text } from "@radix-ui/themes"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { type AbbreviationsSchema } from "@yamori/proto/yamori/workspace/v1/abbreviations_pb.js"; import { type FC } from "react"; const ResponsiveBadge: FC<BadgeProps> = ({ children, ...rest }) => { return ( <Badge {...rest}> <Text truncate>{children}</Text> </Badge> ); }; export interface RecordKindBadgeProps { abbreviations?: proto.MessageShape<typeof AbbreviationsSchema>; record: proto.MessageShape<typeof RecordKindSchema>; } export const RecordKindBadge: FC<RecordKindBadgeProps & BadgeProps> = ({ abbreviations, record, ...rest }) => { switch (record.kind.case) { case "worked": return ( <ResponsiveBadge {...rest} color="blue"> {abbreviations?.worked || "出勤"} </ResponsiveBadge> ); case "dayOff": return ( <ResponsiveBadge {...rest} color="red"> {abbreviations?.dayoff || "休日"} </ResponsiveBadge> ); case "skipped": return ( <ResponsiveBadge {...rest} color="amber"> {abbreviations?.skipWork || "欠勤"} </ResponsiveBadge> ); case "paidLeave": return ( <ResponsiveBadge {...rest} color="green"> {abbreviations?.paidLeave || "有給休暇"} </ResponsiveBadge> ); case "workspaceDefinedLeave": return ( <ResponsiveBadge {...rest} color={ record.kind.value.currentRevision?.snapshot?.isWorkerDeemedToBeWorked ? "green" : "amber" } > {record.kind.value.abbreviationName || record.kind.value.displayName || "休暇"} </ResponsiveBadge> ); default: return null; } };
-
-
-
@@ -1,37 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Theme } from "@radix-ui/themes"; import { type FC, type ReactNode, useEffect, useState } from "react"; import { ToastProvider } from "../contexts/Toast.tsx"; export interface ThemeProviderProps { className?: string | undefined; children: ReactNode; } export const ThemeProvider: FC<ThemeProviderProps> = ({ children, className }) => { const [isDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)")); // `MediaQueryList.matches` は常に最新の状態を表すため、別に保存してその値を読む // 必要はない。この state は単に再レンダリングを引き起こすためだけのもの。 const [, setIsDark] = useState(() => isDark.matches); useEffect(() => { const listener = (event: MediaQueryListEvent) => { setIsDark(event.matches); }; isDark.addEventListener("change", listener); return () => void isDark.removeEventListener("change", listener); }, [isDark]); return ( <Theme className={className} appearance={isDark.matches ? "dark" : "light"}> <ToastProvider>{children}</ToastProvider> </Theme> ); };
-
-
-
@@ -1,31 +0,0 @@/** * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .toast { align-items: center; background-color: var(--accent-3); box-shadow: var(--shadow-3); } @keyframes slide-in { from { transform: translateX(-20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } .toast[data-state="open"] { animation: 0.3s 0s ease-out 1 both slide-in; } .toast[data-swipe="move"] { transform: translateX(var(--radix-toast-swipe-move-x)); }
-
-
-
@@ -1,57 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { CursorArrowIcon } from "@radix-ui/react-icons"; import { Provider, Viewport } from "@radix-ui/react-toast"; import { Flex, Button } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { Toast } from "./Toast.tsx"; export default { component: Toast, args: { title: "Title", description: "Description", icon: <CursorArrowIcon />, action: <Button size="1">やり直す</Button>, dismissible: true, open: true, }, render(args) { return ( <Provider> <Toast {...args} /> <Viewport asChild> <Flex /> </Viewport> </Provider> ); }, } satisfies Meta<typeof Toast>; type Story = StoryObj<typeof Toast>; export const Info: Story = { args: { severity: "info", }, }; export const Success: Story = { args: { severity: "success", }, }; export const Warn: Story = { args: { severity: "warn", }, }; export const Danger: Story = { args: { severity: "danger", }, };
-
-
-
@@ -1,90 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as RadixToast from "@radix-ui/react-toast"; import { Button, Callout, Flex, Text } from "@radix-ui/themes"; import { type ComponentProps, type FC, type ReactElement, type ReactNode } from "react"; import css from "./Toast.module.css"; export type Severity = "info" | "success" | "warn" | "danger"; function severityToColor( severity: Severity, ): ComponentProps<(typeof Callout)["Root"]>["color"] { switch (severity) { case "info": return "blue"; case "success": return "grass"; case "warn": return "amber"; case "danger": return "red"; } } export interface ToastProps extends Omit<RadixToast.ToastProps, "asChild" | "children" | "title"> { icon?: ReactElement; title: ReactNode; description?: ReactNode; action?: ReactElement; severity?: Severity; dismissible?: boolean; } export const Toast: FC<ToastProps> = ({ icon, title, description, action, dismissible, severity = "info", ...rest }) => { return ( <RadixToast.Root {...rest} asChild> <Callout.Root className={css.toast} size="1" color={severityToColor(severity)} style={{ alignItems: "center", backgroundColor: "var(--accent-3)" }} > {icon && <Callout.Icon>{icon}</Callout.Icon>} <Flex asChild align="center" gap="4" width="100%"> <Callout.Text> <Flex as="span" flexGrow="1" flexShrink="1" direction="column"> <RadixToast.Title asChild> <Text weight="bold">{title}</Text> </RadixToast.Title> {description && ( <RadixToast.Description asChild> <Text size="2">{description}</Text> </RadixToast.Description> )} </Flex> {(action || dismissible) && ( <Flex as="span" direction="column" gap="1"> { // <RadixToast.Action> は altText がないとエラーになる不具合が // あるため利用していない。 action } {dismissible && ( <RadixToast.Close asChild> <Button type="button" size="1" variant="surface"> 閉じる </Button> </RadixToast.Close> )} </Flex> )} </Callout.Text> </Flex> </Callout.Root> </RadixToast.Root> ); };
-
-
-
@@ -1,459 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button, Flex, Heading, Select, Switch, TextField } from "@radix-ui/themes"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC, type FormEventHandler, type ReactElement, type ReactNode } from "react"; import { Controller, useForm, type UseFormReturn } from "react-hook-form"; import * as FormField from "./FormField.ts"; export interface UpdateFields { name: string; displayName: string; isAdmin: boolean; canAddUser: boolean; canDeleteRegularUser: boolean; canReadOtherUserProfile: boolean; canUpdateOtherRegularUserProfile: boolean; canUpdateSelfProfile: boolean; canUpdateOtherRegularUserLoginMethod: boolean; canUpdateWorkspace: boolean; } export interface CreateFields extends UpdateFields { loginPassword: string; } interface RootProps { children: ReactNode; onSubmit?: FormEventHandler<HTMLFormElement>; } const Root: FC<RootProps> = ({ children, onSubmit }) => { return ( <Flex direction="column" gap="5"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={onSubmit}>{children}</form> </Flex> </Flex> ); }; interface ProfileFieldsProps { children?: ReactElement; disabled?: boolean; loginUser: User; form: UseFormReturn<UpdateFields, any, undefined>; } const ProfileFields: FC<ProfileFieldsProps> = ({ children, disabled, loginUser, form, }) => { return ( <> <Heading as="h2">基本情報</Heading> <FormField.Root> <FormField.Label htmlFor="c_name">ユーザ名</FormField.Label> <TextField.Root id="c_name" disabled={disabled} placeholder="hanako_nihon" color={form.formState.errors.name ? "red" : undefined} aria-invalid={!!form.formState.errors.name} autoComplete="username" {...form.register("name", { required: "必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.name?.message}> ログイン時に利用するユーザ名です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_displayName">表示名</FormField.Label> <TextField.Root id="c_displayName" disabled={disabled} placeholder="日本 花子" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> 他のユーザも閲覧可能な画面上の表示名です。 未指定の場合はユーザ名が設定されます。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_role">ユーザ種別</FormField.Label> <Controller control={form.control} name="isAdmin" render={({ field: { onChange: _onChange, value, ...field } }) => { return ( <Select.Root disabled={!loginUser.isAdmin || disabled} value={value ? "admin" : "regular"} onValueChange={(value) => void form.setValue(field.name, value === "admin") } > <Select.Trigger id="c_role"></Select.Trigger> <Select.Content> <Select.Item value="regular">一般ユーザ</Select.Item> <Select.Item value="admin">管理者</Select.Item> </Select.Content> </Select.Root> ); }} /> {!loginUser.isAdmin && ( <FormField.Description> 一般ユーザは管理者の作成・変更を行えません。 </FormField.Description> )} </FormField.Root> {children} </> ); }; interface PasswordFieldProps { disabled?: boolean; form: UseFormReturn<CreateFields>; } const PasswordField: FC<PasswordFieldProps> = ({ disabled, form }) => { return ( <FormField.Root> <FormField.Label htmlFor="c_loginPassword">ログインパスワード</FormField.Label> <TextField.Root id="c_loginPassword" disabled={disabled} color={form.formState.errors.loginPassword ? "red" : undefined} aria-invalid={!!form.formState.errors.loginPassword} type="password" autoComplete="do_not_complete_idiot" {...form.register("loginPassword", { required: "必須です", minLength: { value: 8, message: "8文字以上を入力してください", }, })} /> <FormField.Description error={form.formState.errors.loginPassword?.message}> 新たに作成されるユーザが利用するログインパスワードです。 </FormField.Description> </FormField.Root> ); }; interface PermissionFieldsProps { loginUser: User; disabled?: boolean; form: UseFormReturn<UpdateFields>; } const PermissionFields: FC<PermissionFieldsProps> = ({ loginUser, disabled, form }) => { const isAdmin = form.watch("isAdmin"); return ( <> <Heading as="h2">権限</Heading> <FormField.Root> <FormField.Label htmlFor="c_canUpdateSelfProfile"> アカウント情報編集 </FormField.Label> <Controller control={form.control} name="canUpdateSelfProfile" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateSelfProfile" disabled={ disabled || isAdmin || !loginUser.permissions?.canUpdateSelfProfile } checked={isAdmin || value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 作成されたユーザが自身の表示名を変更するための権限です。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_canAddUser">ユーザ作成</FormField.Label> <Controller control={form.control} name="canAddUser" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canAddUser" disabled={disabled || isAdmin || !loginUser.permissions?.canAddUser} checked={isAdmin || value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> システムに新しいユーザを作成・登録する権限です。 このユーザに与えられた権限と同じかそれ未満のユーザのみ作成・登録ができます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_canDeleteRegularUser">ユーザ削除</FormField.Label> <Controller control={form.control} name="canDeleteRegularUser" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canDeleteRegularUser" disabled={ disabled || isAdmin || !loginUser.permissions?.canDeleteRegularUser } checked={isAdmin || value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> ユーザを削除する権限です。 管理者の削除は管理者のみ行えます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_canReadOtherUserProfile"> 他ユーザ情報閲覧 </FormField.Label> <Controller control={form.control} name="canReadOtherUserProfile" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canReadOtherUserProfile" disabled={ disabled || isAdmin || !loginUser.permissions?.canReadOtherUserProfile } checked={isAdmin || value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 他のユーザの基本情報を閲覧する権限です。 ユーザ一覧の表示に必要となります。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_canUpdateOtherRegularUserProfile"> 他ユーザ情報編集 </FormField.Label> <Controller control={form.control} name="canUpdateOtherRegularUserProfile" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateOtherRegularUserProfile" disabled={ disabled || isAdmin || !loginUser.permissions?.canUpdateOtherRegularUserProfile } checked={isAdmin || value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 他のユーザの基本情報を編集する権限です。 管理者に対する変更は管理者のみ行えます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_canUpdateOtherRegularUserLoginMethod"> 他ユーザログイン設定変更 </FormField.Label> <Controller control={form.control} name="canUpdateOtherRegularUserLoginMethod" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateOtherRegularUserLoginMethod" disabled={ disabled || isAdmin || !loginUser.permissions?.canUpdateOtherRegularUserLoginMethod } checked={isAdmin || value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> 他のユーザのパスワードを変更する権限です。 管理者に対する変更は管理者のみ行えます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_canUpdateWorkspace"> ワークスペース設定変更 </FormField.Label> <Controller control={form.control} name="canUpdateWorkspace" render={({ field: { onChange: _onChange, value, ...field } }) => ( <Switch id="c_canUpdateWorkspace" disabled={disabled || isAdmin || !loginUser.permissions?.canUpdateWorkspace} checked={isAdmin || value} onCheckedChange={(newValue) => { form.setValue(field.name, newValue); }} {...field} /> )} /> <FormField.Description> カスタム休暇の登録やカスタム属性の登録、年次有給休暇の払い出しテーブルの変更といった ワークスペース全体の変更を行う権限です。 </FormField.Description> </FormField.Root> </> ); }; export interface CreateFormProps { loginUser: User; pending?: boolean; onCreate?(fields: CreateFields): void; } export const CreateForm: FC<CreateFormProps> = ({ loginUser, pending, onCreate }) => { const form = useForm<CreateFields>({ defaultValues: { name: "", displayName: "", loginPassword: "", canAddUser: false, canDeleteRegularUser: false, canReadOtherUserProfile: false, canUpdateOtherRegularUserProfile: false, canUpdateSelfProfile: loginUser.permissions?.canUpdateSelfProfile ?? false, canUpdateOtherRegularUserLoginMethod: false, canUpdateWorkspace: false, }, mode: "onBlur", }); return ( <Root onSubmit={form.handleSubmit((values) => { onCreate?.(values); })} > <ProfileFields disabled={pending} // @ts-expect-error react-hook-form における型設計ミス // https://github.com/react-hook-form/react-hook-form/issues/6726 form={form} loginUser={loginUser} > <PasswordField disabled={pending} form={form} /> </ProfileFields> <PermissionFields disabled={pending} // @ts-expect-error react-hook-form における型設計ミス // https://github.com/react-hook-form/react-hook-form/issues/6726 form={form} loginUser={loginUser} /> <Button loading={pending}>作成</Button> </Root> ); }; export interface UpdateFormProps { loginUser: User; user: User; pending?: boolean; onUpdate?(fields: UpdateFields): void; } export const UpdateForm: FC<UpdateFormProps> = ({ loginUser, user, pending, onUpdate, }) => { const form = useForm<UpdateFields>({ defaultValues: { name: user.name, displayName: user.displayName, isAdmin: user.isAdmin, canAddUser: !!user.permissions?.canAddUser, canDeleteRegularUser: !!user.permissions?.canDeleteRegularUser, canReadOtherUserProfile: !!user.permissions?.canReadOtherUserProfile, canUpdateOtherRegularUserProfile: !!user.permissions?.canUpdateOtherRegularUserProfile, canUpdateSelfProfile: !!user.permissions?.canUpdateSelfProfile, canUpdateOtherRegularUserLoginMethod: !!user.permissions?.canUpdateOtherRegularUserLoginMethod, canUpdateWorkspace: !!user.permissions?.canUpdateWorkspace, }, mode: "onBlur", }); return ( <Root onSubmit={form.handleSubmit((values) => { onUpdate?.(values); })} > <ProfileFields disabled={pending} form={form} loginUser={loginUser} /> <PermissionFields disabled={pending} form={form} loginUser={loginUser} /> <Button loading={pending}>更新</Button> </Root> ); };
-
-
-
@@ -1,128 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { WorkRecordBadges } from "./WorkRecordBadges.tsx"; export default { component: WorkRecordBadges, args: { workRecord: proto.create(WorkRecordSchema), }, } satisfies Meta<typeof WorkRecordBadges>; type Story = StoryObj<typeof WorkRecordBadges>; export const Unknown: Story = {}; export const DayOff: Story = { args: { workRecord: proto.create(WorkRecordSchema, { kind: { case: "dayWhole", value: { kind: { case: "dayOff", value: {}, }, }, }, }), }, }; export const WorkingDay: Story = { args: { workRecord: proto.create(WorkRecordSchema, { kind: { case: "dayWhole", value: { kind: { case: "worked", value: { hourlyPaidLeave: { hours: 3, }, }, }, }, }, }), }, }; export const SkippedWork: Story = { args: { workRecord: proto.create(WorkRecordSchema, { kind: { case: "dayWhole", value: { kind: { case: "skipped", value: {}, }, }, }, }), }, }; export const PaidLeave: Story = { args: { workRecord: proto.create(WorkRecordSchema, { kind: { case: "dayWhole", value: { kind: { case: "paidLeave", value: {}, }, }, }, }), }, }; export const WorkspaceDefinedLeave: Story = { args: { workRecord: proto.create(WorkRecordSchema, { kind: { case: "dayWhole", value: { kind: { case: "workspaceDefinedLeave", value: { displayName: "リフレッシュ休暇", }, }, }, }, }), }, }; export const LeaveDeemedToBeWorked: Story = { args: { workRecord: proto.create(WorkRecordSchema, { kind: { case: "dayWhole", value: { kind: { case: "workspaceDefinedLeave", value: { displayName: "産前産後休業", currentRevision: { snapshot: { isWorkerDeemedToBeWorked: true, }, }, }, }, }, }, }), }, };
-
-
-
@@ -1,58 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { type BadgeProps } from "@radix-ui/themes"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { type AbbreviationsSchema } from "@yamori/proto/yamori/workspace/v1/abbreviations_pb.js"; import { type FC } from "react"; import { RecordKindBadge } from "./RecordKindBadge.tsx"; export interface WorkRecordBadgesProps extends Pick< BadgeProps, "size" | "m" | `m${"t" | "b" | "r" | "l" | "x" | "y"}` | "style" | "className" > { abbreviations?: proto.MessageShape<typeof AbbreviationsSchema>; workRecord: proto.MessageShape<typeof WorkRecordSchema>; } export const WorkRecordBadges: FC<WorkRecordBadgesProps> = ({ abbreviations, workRecord, ...rest }) => { switch (workRecord.kind.case) { case "dayWhole": return ( <RecordKindBadge {...rest} abbreviations={abbreviations} record={workRecord.kind.value} /> ); case "dayHalved": return ( <> {workRecord.kind.value.am && ( <RecordKindBadge {...rest} abbreviations={abbreviations} record={workRecord.kind.value.am} /> )} {workRecord.kind.value.pm && ( <RecordKindBadge {...rest} abbreviations={abbreviations} record={workRecord.kind.value.pm} /> )} </> ); default: return null; } };
-
-
-
@@ -1,29 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type Transport } from "@connectrpc/connect"; import { createContext, type FC, type ReactNode, use } from "react"; const Context = createContext<Transport | null>(null); export interface ConnectTransportProviderProps { children: ReactNode; transport: Transport; } export const ConnectTransportProvider: FC<ConnectTransportProviderProps> = ({ children, transport, }) => { return <Context.Provider value={transport}>{children}</Context.Provider>; }; export function useConnectTransport(): Transport { const transport = use(Context); if (!transport) { throw new Error("`useConnectTransport` called outside `ConnectTransportProvider`"); } return transport; }
-
-
-
@@ -1,332 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../polyfill.ts"; import { createContext, type FC, type ReactNode, use, useEffect, useMemo, useRef, useState, } from "react"; export const URLContext = createContext<URL>(new URL(location.href)); export interface Navigation { replace(url: string | URL): void; push(url: string | URL): void; forward(): void; back(): void; } const nativeNavigation: Navigation = { push(url) { location.href = url instanceof URL ? url.href : url; }, replace(url) { location.href = url instanceof URL ? url.href : url; }, forward() { history.forward(); }, back() { history.back(); }, }; export const NavigationContext = createContext<Navigation>(nativeNavigation); export interface HistoryAPIRouterProviderProps { children: ReactNode; baseURL?: string | URL | undefined; } export const HistoryAPIRouterProvider: FC<HistoryAPIRouterProviderProps> = ({ baseURL, children, }) => { const [url, setURL] = useState(() => new URL(location.href)); const navigation = useMemo<Navigation>(() => { return { push(url) { history.pushState({}, "", url); setURL(new URL(url, location.href)); }, replace(url) { history.replaceState({}, "", url); setURL(new URL(url, location.href)); }, back() { history.back(); setURL(new URL(location.href)); }, forward() { history.forward(); setURL(new URL(location.href)); }, }; }, []); useEffect(() => { const listener = (_event: PopStateEvent) => { setURL(new URL(location.href)); }; window.addEventListener("popstate", listener); return () => void window.removeEventListener("popstate", listener); }, []); useEffect(() => { const root = new URL(baseURL ?? "/", url); const listener = (event: MouseEvent) => { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } if (!(event.target instanceof Element)) { return; } const anchor = event.target instanceof HTMLAnchorElement ? event.target : event.target.closest("a"); if ( !anchor || !anchor.href || (anchor.hasAttribute("target") && anchor.getAttribute("target") !== "_self") ) { return; } const href = new URL(anchor.href, url); if (!href.toString().startsWith(root.toString())) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); navigation.push(href); }; document.addEventListener("click", listener); return () => void document.removeEventListener("click", listener); }, [baseURL, url, navigation]); return ( <NavigationContext.Provider value={navigation}> <URLContext.Provider value={url}>{children}</URLContext.Provider> </NavigationContext.Provider> ); }; export interface InmemoryRouterProviderProps { initialURL?: string | URL | undefined; baseURL?: string | URL | undefined; children: ReactNode; onRouteChange?(url: URL): void; } export const InmemoryRouterProvider: FC<InmemoryRouterProviderProps> = ({ initialURL, baseURL, children, onRouteChange, }) => { const [history, setHistory] = useState<{ current: URL; backwards: readonly URL[]; forwards: readonly URL[]; }>(() => { return { current: new URL(initialURL ?? location.href, location.href), forwards: [], backwards: [], }; }); const routeChangeCallback = useRef(onRouteChange); routeChangeCallback.current = onRouteChange; const navigation = useMemo<Navigation>(() => { return { push(url) { setHistory(({ current, backwards }) => { const next = new URL(url, current); routeChangeCallback.current?.(next); return { current: next, backwards: [current, ...backwards], forwards: [], }; }); }, replace(url) { setHistory(({ current, backwards, forwards }) => { const next = new URL(url, current); routeChangeCallback.current?.(next); return { current: next, backwards, forwards, }; }); }, forward() { setHistory((history) => { if (!history.forwards[0]) { return history; } routeChangeCallback.current?.(history.forwards[0]); return { current: history.forwards[0], backwards: [history.current, ...history.backwards], forwards: history.forwards.slice(1), }; }); }, back() { setHistory((history) => { if (!history.backwards[0]) { return history; } routeChangeCallback.current?.(history.backwards[0]); return { current: history.backwards[0], backwards: history.backwards.slice(1), forwards: [history.current, ...history.forwards], }; }); }, }; }, []); const root = useMemo(() => new URL(baseURL ?? location.href, location.href), [baseURL]); useEffect(() => { const listener = (event: MouseEvent) => { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } if (!(event.target instanceof Element)) { return; } const anchor = event.target instanceof HTMLAnchorElement ? event.target : event.target.closest("a"); if ( !anchor || !anchor.href || (anchor.hasAttribute("target") && anchor.getAttribute("target") !== "_self") ) { return; } const href = new URL(anchor.href, history.current); if (!href.toString().startsWith(root.toString())) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); navigation.push(href); }; document.addEventListener("click", listener); return () => void document.removeEventListener("click", listener); }, [root, history.current, navigation]); return ( <NavigationContext.Provider value={navigation}> <URLContext.Provider value={history.current}>{children}</URLContext.Provider> </NavigationContext.Provider> ); }; const URLPatternResultContext = createContext<URLPatternResult | null>(null); export function useURLPatternResult(): URLPatternResult { const result = use(URLPatternResultContext); if (!result) { throw new Error("Router invariant: useURLPatternResult was called outside <On/>."); } return result; } export interface SelectProps { routes: readonly { children: ReactNode; pattern: URLPattern; }[]; fallback?: ReactNode; } export const Select: FC<SelectProps> = ({ routes, fallback }) => { const url = use(URLContext); for (const route of routes) { const result = route.pattern.exec(url); if (!result) { continue; } return ( <URLPatternResultContext.Provider value={result}> {route.children} </URLPatternResultContext.Provider> ); } return fallback; }; export interface OnProps { children: ReactNode; pattern: URLPattern; } export const On: FC<OnProps> = ({ children, pattern }) => { const url = use(URLContext); const result = useMemo(() => pattern.exec(url), [pattern, url]); if (!result) { return null; } return ( <URLPatternResultContext.Provider value={result}> {children} </URLPatternResultContext.Provider> ); };
-
-
-
@@ -1,179 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type DescMessage, type MessageInitShape, type MessageShape, create, toBinary, fromBinary, } from "@bufbuild/protobuf"; import { type QueryClientConfig, QueryClient, QueryClientProvider, type MutationOptions, type UseMutationResult, useMutation, type QueryOptions, type UseQueryResult, useQuery, } from "@tanstack/react-query"; import { createContext, type FC, type ReactNode, use, useState } from "react"; export interface ProtoRPC { send(service: string, method: string, data: Uint8Array): Promise<Uint8Array>; } const Context = createContext<ProtoRPC | null>(null); export interface ProtoRPCProviderProps { children: ReactNode; rpc: ProtoRPC; config?: QueryClientConfig; } export const ProtoRPCProvider: FC<ProtoRPCProviderProps> = ({ children, rpc, config, }) => { const [queryClient] = useState(() => new QueryClient(config)); return ( <Context.Provider value={rpc}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </Context.Provider> ); }; export function useRPC(): ProtoRPC { const rpc = use(Context); if (!rpc) { throw new Error("`useService` MUST be called inside `ServiceProvider`"); } return rpc; } export interface UseMethodQueryParams< Request extends DescMessage, Response extends DescMessage, Output = MessageShape<Response>, > { readonly service: string; readonly method: string; readonly request: { readonly schema: Request; readonly data: MessageInitShape<Request>; }; readonly response: { readonly schema: Response; }; mapResponse?(response: MessageShape<Response>): Output; readonly options?: Omit<QueryOptions<Output>, "queryKey" | "queryFn">; } export function useMethodQuery< Request extends DescMessage, Response extends DescMessage, Output = MessageShape<Response>, >({ service, method, request, response, mapResponse, options, }: UseMethodQueryParams<Request, Response, Output>): UseQueryResult<Output, unknown> { const rpc = useRPC(); return useQuery({ ...options, queryKey: [service, method, request.data], async queryFn() { const received = await rpc.send( service, method, toBinary(request.schema, create(request.schema, request.data)), ); const resp = fromBinary(response.schema, received); if (mapResponse) { return mapResponse(resp); } return resp as Output; }, }); } export interface UseMethodMutationParams< Request extends DescMessage, Response extends DescMessage, Output = MessageShape<Response>, > { readonly service: string; readonly method: string; readonly request: { readonly schema: Request; }; readonly response: { readonly schema: Response; }; mapResponse?(response: MessageShape<Response>): Output; readonly options?: Omit< MutationOptions<Output, unknown, MessageInitShape<Request>>, "mutationFn" >; } export function useMethodMutation< Request extends DescMessage, Response extends DescMessage, Output = MessageShape<Response>, >({ service, method, request, response, mapResponse, options, }: UseMethodMutationParams<Request, Response, Output>): UseMutationResult< Output, unknown, MessageInitShape<Request> > { const rpc = useRPC(); return useMutation({ ...options, async mutationFn(req) { const received = await rpc.send( service, method, toBinary(request.schema, create(request.schema, req)), ); const resp = fromBinary(response.schema, received); if (mapResponse) { return mapResponse(resp); } return resp as Output; }, }); }
-
-
packages/react_ui/src/contexts/Toast.tsx (deleted)
-
@@ -1,80 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as RadixToast from "@radix-ui/react-toast"; import { Flex } from "@radix-ui/themes"; import { createContext, type FC, type ReactNode, use, useMemo, useState } from "react"; import { Toast, type ToastProps } from "../components/Toast.tsx"; interface ToastController { open(props: ToastProps): () => void; } const ToastContext = createContext<ToastController>({ open() { return () => {}; }, }); export interface ToastProviderProps { children: ReactNode; } export const ToastProvider: FC<ToastProviderProps> = ({ children }) => { const [toasts, setToasts] = useState<readonly (ToastProps & { key: string })[]>([]); const controller = useMemo<ToastController>(() => { return { open(props) { const queue: ToastProps & { key: string } = { ...props, key: crypto.randomUUID(), onOpenChange(open) { props.onOpenChange?.(open); if (!open) { setToasts((prev) => prev.filter((p) => p !== queue)); } }, }; setToasts((prev) => [...prev, queue]); return () => void setToasts((prev) => prev.filter((p) => p !== queue)); }, }; }, []); return ( <RadixToast.Provider duration={3_000}> <ToastContext.Provider value={controller}>{children}</ToastContext.Provider> {toasts.map(({ key, ...toast }) => ( <Toast key={key} {...toast} style={{ pointerEvents: "auto", viewTransitionName: `toast-${key}` }} /> ))} <RadixToast.Viewport asChild> <Flex position="absolute" top={{ initial: "0", md: "unset" }} right="0" bottom={{ md: "0" }} left={{ initial: "0", md: "unset" }} p="2" direction={{ initial: "column", md: "column-reverse" }} align="center" gap="2" overflowX="hidden" style={{ pointerEvents: "none", zIndex: 999 }} /> </RadixToast.Viewport> </RadixToast.Provider> ); }; export function useToast() { return use(ToastContext); }
-
-
-
@@ -1,8 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export class IllegalMessageError extends Error { constructor(public readonly data: unknown) { super("Received a message that does not comform schema"); } }
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only /** * ユーザの入力に対するエラー。 * そのまま表示するもの。 */ export class UserInputError extends Error { constructor(message: string) { super(message); } }
-
-
packages/react_ui/src/helpers.ts (deleted)
-
@@ -1,33 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { TZDate, tz } from "@date-fns/tz"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { startOfDay, type DateArg } from "date-fns"; // 全ての日付は JST で計算される const TZ = "Asia/Tokyo"; export function fromProtoDate(d: proto.MessageShape<typeof DateSchema>): Date { return TZDate.tz(TZ, d.year, d.month - 1, d.day, 0, 0, 0); } export function toProtoDate<D extends Date>( d: DateArg<D>, ): proto.MessageShape<typeof DateSchema> { const start = startOfDay(d, { in: tz(TZ) }); return proto.create(DateSchema, { year: start.getFullYear(), month: start.getMonth() + 1, day: start.getDate(), }); } export function isSameDate( a: proto.MessageShape<typeof DateSchema>, b: proto.MessageShape<typeof DateSchema>, ): boolean { return a.year === b.year && a.month === b.month && a.day === b.day; }
-
-
-
@@ -1,23 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { flushSync } from "react-dom"; type ViewTransitionCallback = () => Promise<void> | void; function runTransition(callback: ViewTransitionCallback) { if (!document.startViewTransition) { callback(); return; } document.startViewTransition(() => { flushSync(() => { callback(); }); }); } export function useViewTransition(): (callback: ViewTransitionCallback) => void { return runTransition; }
-
-
packages/react_ui/src/lib.ts (deleted)
-
@@ -1,13 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "@radix-ui/themes/styles.css"; import "./radix_workaround.css"; export * from "./components/ThemeProvider.tsx"; export { ThirdPartyNoticeProvider } from "./components/CopyrightNotice.ts"; export type { ThirdPartyNoticeProviderProps } from "./components/CopyrightNotice.ts"; export * from "./contexts/Service.tsx"; export * from "./contexts/Router.tsx"; export * from "./contexts/ConnectTransport.tsx"; export * from "./pages/page.tsx";
-
-
-
@@ -1,429 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { type CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { type GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { WorkerService } from "@yamori/proto/yamori/worker/v1/worker_service_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { subDays } from "date-fns"; import { mock, type MockOptions, } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { toProtoDate } from "../../../../helpers.ts"; export interface ListOptions extends MockOptions { workspace?: MessageInitShape<typeof WorkspaceSchema>; workers?: MessageInitShape<typeof WorkerSchema>[]; failureRate?: number; } export function List({ workspace = { id: { value: "ws-foo", }, }, workers = [ { id: { value: "wr-foo", }, displayName: "日本 太郎", providePaidLeaveKey: { key: new Uint8Array([]) }, workRecords: [ { date: toProtoDate(subDays(Date.now(), 1)), kind: { case: "dayWhole", value: { kind: { case: "paidLeave", value: {}, }, }, }, }, ], }, { id: { value: "wr-bar", }, displayName: "行政 花子", writeWorkRecordKey: { key: new Uint8Array([]) }, workRecords: [ { date: toProtoDate(subDays(Date.now(), 2)), kind: { case: "dayHalved", value: { am: { kind: { case: "worked", value: { hourlyPaidLeave: { hours: 3, }, }, }, }, pm: { kind: { case: "paidLeave", value: {}, }, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 3)), kind: { case: "dayWhole", value: { kind: { case: "worked", value: {}, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 4)), kind: { case: "dayWhole", value: { kind: { case: "skipped", value: {}, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 5)), kind: { case: "dayWhole", value: { kind: { case: "dayOff", value: {}, }, }, }, }, ], }, { id: { value: "wr-qux", }, displayName: "浦島 次郎", providePaidLeaveKey: { key: new Uint8Array([]) }, writeWorkRecordKey: { key: new Uint8Array([]) }, workRecords: [ { date: toProtoDate(subDays(Date.now(), 2)), kind: { case: "dayWhole", value: { kind: { case: "worked", value: {}, }, }, }, }, ], }, { id: { value: "wr-baz", }, }, ], failureRate = 0, ...options }: ListOptions = {}) { return mock( WorkerService.method.list, () => { if (Math.random() < failureRate) { return { result: { case: "systemError" as const, value: { code: "MOCK_ERR", message: "This is sample error message.", }, }, }; } return { result: { case: "ok" as const, value: { workers, workspaceId: workspace.id! }, }, }; }, options, ); } export interface CreateOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof CreateResponseSchema>["result"], { case: "ok" | undefined } >; } export function Create({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is sample error message from mock handler for yamori.worker.v1.WorkerService.Create", }, }, ...options }: CreateOptions = {}) { return mock( WorkerService.method.create, (req) => { if (Math.random() < failureRate) { return { result: error, }; } const id = "wr-" + crypto.randomUUID(); return { result: { case: "ok" as const, value: { worker: { id: { value: id }, displayName: req.displayName, }, }, }, }; }, options, ); } export interface GetOptions extends MockOptions { failureRate?: number; worker?: MessageInitShape<typeof WorkerSchema>; error?: Exclude< MessageInitShape<typeof GetResponseSchema>["result"], { case: "ok" | undefined } >; } export function Get({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is mock error message for yamori.worker.v1.WorkerService.Get", }, }, worker = { id: { value: "wr-alice" }, displayName: "Alice", paidLeaveProvisions: [ { providedAt: { year: 2024, month: 3, day: 1, }, expiresAt: { year: 2026, month: 3, day: 1, }, amountDays: 10, remainingDays: 3, isHalvedDayRemaining: true, }, { providedAt: { year: 2025, month: 3, day: 1, }, expiresAt: { year: 2027, month: 3, day: 1, }, amountDays: 10, remainingDays: 10, }, ], workRecords: [ { date: toProtoDate(subDays(Date.now(), 7)), kind: { case: "dayWhole", value: { kind: { case: "dayOff", value: {}, }, }, }, note: "やる気がないので休日", }, { date: toProtoDate(subDays(Date.now(), 6)), kind: { case: "dayWhole", value: { kind: { case: "paidLeave", value: { providedAt: { year: 2024, month: 3, day: 1, }, }, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 5)), kind: { case: "dayWhole", value: { kind: { case: "workspaceDefinedLeave", value: { displayName: "産前産後休業", currentRevision: { snapshot: { isWorkerDeemedToBeWorked: true, }, }, }, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 4)), kind: { case: "dayWhole", value: { kind: { case: "workspaceDefinedLeave", value: { displayName: "冠婚葬祭", }, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 3)), kind: { case: "dayWhole", value: { kind: { case: "skipped", value: {}, }, }, }, note: "寝坊", }, { date: toProtoDate(subDays(Date.now(), 2)), kind: { case: "dayWhole", value: { kind: { case: "worked", value: {}, }, }, }, }, { date: toProtoDate(subDays(Date.now(), 1)), kind: { case: "dayHalved", value: { am: { kind: { case: "paidLeave", value: { providedAt: { year: 2024, month: 3, day: 1, }, }, }, }, pm: { kind: { case: "worked", value: { hourlyPaidLeave: { providedAt: { year: 2024, month: 3, day: 1, }, hours: 2, }, }, }, }, }, }, }, ], }, ...options }: GetOptions = {}) { return mock( WorkerService.method.get, () => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: worker, }, }; }, options, ); }
-
-
-
@@ -1,338 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { CreateResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_response_pb.js"; import { type CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { type DeleteLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_response_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v1/workspace_service_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { mock, type MockOptions, } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; export interface GetOptions extends MockOptions { workspace?: MessageInitShape<typeof WorkspaceSchema>; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.get.output>["result"], { case: "ok" | undefined } >; failureRate?: number; } export function Get({ workspace = { id: { value: "ws-foo" }, displayName: "株式会社あああ", abbreviations: { worked: "出勤", dayoff: "休日", skipWork: "欠勤", paidLeave: "年休", }, leaveDefinitions: [ { id: { value: "lv-foo" }, displayName: "産前産後休業", abbreviationName: "産休", currentRevision: { revisionId: { value: "lr-foo-foo" }, startAt: { year: 1947, month: 9, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, revisions: [ { revisionId: { value: "lr-foo-foo" }, startAt: { year: 1947, month: 9, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, { id: { value: "lv-bar" }, displayName: "忌引", abbreviationName: "忌引", deletionKey: { key: new Uint8Array([]) }, currentRevision: { revisionId: { value: "lr-bar-foo" }, startAt: { year: 2010, month: 10, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, revisions: [ { revisionId: { value: "lr-bar-foo" }, startAt: { year: 2010, month: 10, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, { revisionId: { value: "lr-bar-bar" }, startAt: { year: 2100, month: 10, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, { id: { value: "lv-baz" }, displayName: "リフレッシュ休暇", abbreviationName: "リ休", deletionKey: { key: new Uint8Array([]) }, revisions: [ { revisionId: { value: "lr-baz-foo" }, startAt: { year: 2010, month: 10, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, ], paidLeaveProvisionTables: [ { id: { value: "pt-foo" }, displayName: "テストテーブル1", currentRevision: { firstProvisionAmountDays: 10, subsequentProvisionAmountDays: [11, 12, 13, 14, 15], }, }, { id: { value: "pt-bar" }, displayName: "テストテーブル2", currentRevision: { firstProvisionAmountDays: 8, subsequentProvisionAmountDays: [9, 9, 10, 11, 12], }, }, ], }, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is sample error message.", }, }, failureRate = 0, ...options }: GetOptions = {}) { return mock( WorkspaceService.method.get, () => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: workspace, }, }; }, options, ); } export interface ListOptions extends MockOptions { workspaces?: MessageInitShape<typeof WorkspaceSchema>[]; failureRate?: number; } export function List({ workspaces = [ { id: { value: "ws-foo" }, displayName: "株式会社あああ", }, { id: { value: "ws-bar" }, displayName: "有限会社いいい", }, { id: { value: "ws-baz" }, displayName: "NPO法人 ううう", }, ], failureRate = 0, ...options }: ListOptions = {}) { return mock( WorkspaceService.method.list, () => { if (Math.random() < failureRate) { return { result: { case: "systemError" as const, value: { code: "MOCK_ERR", message: "This is sample error message.", }, }, }; } return { result: { case: "ok" as const, value: { workspaces }, }, }; }, options, ); } export interface CreateOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof CreateResponseSchema>["result"], { case: "ok" | undefined } >; } export function Create({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is sample error message", }, }, ...options }: CreateOptions = {}) { return mock( WorkspaceService.method.create, (req) => { if (Math.random() < failureRate) { return { result: error, }; } const id = "ws-" + crypto.randomUUID(); return { result: { case: "ok" as const, value: { workspace: { id: { value: id }, displayName: req.displayName, }, }, }, }; }, options, ); } export interface CreateLeaveDefinitionOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof CreateLeaveDefinitionResponseSchema>["result"], { case: "ok" | undefined } >; } export function CreateLeaveDefinition({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is sample error message", }, }, ...options }: CreateLeaveDefinitionOptions = {}) { return mock( WorkspaceService.method.createLeaveDefinition, (req) => { if (Math.random() < failureRate) { return { result: error, }; } const id = "lv-" + crypto.randomUUID(); return { result: { case: "ok" as const, value: { id: { value: id }, displayName: req.leaveDefinition?.displayName, revisions: req.leaveDefinition?.revisions.map((rev) => { return { revisionId: { value: "lv-" + crypto.randomUUID() }, startAt: rev.startAt, snapshot: rev.snapshot, }; }), }, }, }; }, options, ); } export interface DeleteLeaveDefinitionOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof DeleteLeaveDefinitionResponseSchema>["result"], { case: "ok" | undefined } >; result?: Extract< MessageInitShape<typeof DeleteLeaveDefinitionResponseSchema>["result"], { case: "ok" } >; } export function DeleteLeaveDefinition({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is sample error message", }, }, result = { case: "ok", value: { deleted: { displayName: "Foo", }, }, }, ...options }: DeleteLeaveDefinitionOptions = {}) { return mock( WorkspaceService.method.deleteLeaveDefinition, (_req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result, }; }, options, ); }
-
-
-
@@ -1,482 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create, type MessageInitShape } from "@bufbuild/protobuf"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { mock, type MockOptions, } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; export const alice = create(UserSchema, { id: { value: "wu-alice" }, name: "alice", displayName: "Alice", isAdmin: true, permissions: { canAddUser: true, canDeleteRegularUser: true, canReadOtherUserProfile: true, canUpdateOtherRegularUserLoginMethod: true, canUpdateOtherRegularUserProfile: true, canUpdateSelfProfile: true, canUpdateWorkspace: true, }, loginMethod: { passwordConfigured: true, }, }); export const bob = create(UserSchema, { id: { value: "wu-bob" }, name: "bob", displayName: "Bob", permissions: { canUpdateSelfProfile: true, }, loginMethod: { passwordConfigured: true, }, }); export interface CreateInitialAdminOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.createInitialAdmin.output>["result"], { case: "ok" | undefined } >; } export function CreateInitialAdmin({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: CreateInitialAdminOptions = {}) { return mock( WorkspaceService.method.createInitialAdmin, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: { value: "ws-" + crypto.randomUUID(), }, name: req.name.trim(), displayName: req.displayName.trim() || req.name.trim(), isAdmin: true, loginMethod: { passwordConfigured: true, }, }, }, }; }, options, ); } export interface CreateUserOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.createUser.output>["result"], { case: "ok" | undefined } >; } export function CreateUser({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: CreateUserOptions = {}) { return mock( WorkspaceService.method.createUser, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: { value: "wu-" + crypto.randomUUID(), }, name: req.name.trim(), displayName: req.displayName.trim() || req.name.trim(), isAdmin: req.isAdmin, loginMethod: { passwordConfigured: true, }, permissions: req.permissions, }, }, }; }, options, ); } export interface DeleteUserOptions extends MockOptions { failureRate?: number; ok?: Extract< MessageInitShape<typeof WorkspaceService.method.deleteUser.output>["result"], { case: "ok" } >["value"]; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.deleteUser.output>["result"], { case: "ok" | undefined } >; } export function DeleteUser({ failureRate = 0, ok = { id: { value: "wu-foo" }, displayName: "Foo", }, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: DeleteUserOptions = {}) { return mock( WorkspaceService.method.deleteUser, (_req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: ok, }, }; }, options, ); } export interface UpdateUserOptions extends MockOptions { failureRate?: number; ok?: Extract< MessageInitShape<typeof WorkspaceService.method.updateUser.output>["result"], { case: "ok" } >["value"]; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.updateUser.output>["result"], { case: "ok" | undefined } >; } export function UpdateUser({ failureRate = 0, ok = bob, // TODO: モックのエラーはほぼ全てコピペなので定数化する error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: UpdateUserOptions = {}) { return mock( WorkspaceService.method.updateUser, (_req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: ok, }, }; }, options, ); } export interface LoginOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.login.output>["result"], { case: "ok" | undefined } >; } export function Login({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: LoginOptions = {}) { return mock( WorkspaceService.method.login, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: { value: "ws-" + crypto.randomUUID(), }, name: req.name.trim(), displayName: req.name.toUpperCase(), isAdmin: true, loginMethod: { passwordConfigured: true, }, }, }, }; }, options, ); } export interface GetLoginUserOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.getLoginUser.output>["result"], { case: "ok" | undefined } >; } export function GetLoginUser({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: GetLoginUserOptions = {}) { return mock( WorkspaceService.method.getLoginUser, () => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: alice, }, }; }, options, ); } export interface GetOptions extends MockOptions { failureRate?: number; workspace?: Extract< MessageInitShape<typeof WorkspaceService.method.get.output>["result"], { case: "ok" } >["value"]; error?: Exclude< MessageInitShape<typeof WorkspaceService.method.get.output>["result"], { case: "ok" | undefined } >; } export function Get({ failureRate = 0, workspace = { displayName: "Mock Workspace", users: [alice, bob], customAttributeDefinition: [ { id: { value: "cf-foo" }, displayName: "社員ID", }, { id: { value: "cf-bar" }, displayName: "部署", }, ], }, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: GetOptions = {}) { return mock( WorkspaceService.method.get, () => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: workspace, }, }; }, options, ); } export interface PutCustomAttributeDefinitionOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape< typeof WorkspaceService.method.putCustomAttributeDefinition.output >["result"], { case: "ok" | undefined } >; } export function PutCustomAttributeDefinition({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: PutCustomAttributeDefinitionOptions = {}) { return mock( WorkspaceService.method.putCustomAttributeDefinition, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: req.id ?? { value: "cf-" + crypto.randomUUID(), }, displayName: req.displayName, }, }, }; }, options, ); } export interface DeleteCustomAttributeDefinitionOptions extends MockOptions { failureRate?: number; error?: Exclude< MessageInitShape< typeof WorkspaceService.method.deleteCustomAttributeDefinition.output >["result"], { case: "ok" | undefined } >; } export function DeleteCustomAttributeDefinition({ failureRate = 0, error = { case: "systemError", value: { code: "MOCK_ERR", message: "This is a sample error", }, }, ...options }: DeleteCustomAttributeDefinitionOptions = {}) { return mock( WorkspaceService.method.deleteCustomAttributeDefinition, (req) => { if (Math.random() < failureRate) { return { result: error, }; } return { result: { case: "ok" as const, value: { id: req.id ?? { value: "cf-" + crypto.randomUUID(), }, displayName: "Foo", }, }, }; }, options, ); }
-
-
-
@@ -1,50 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .appbar { border-block-end: 1px solid var(--gray-a6); background-color: var(--color-panel-solid); z-index: 50; } .menu { background-color: var(--color-background); z-index: 30; view-transition-name: menu; } .logo { display: inline-flex; padding: var(--space-1); border-radius: var(--radius-1); color: var(--gray-12); } .logo:focus-visible { color: var(--focus-11); outline: 2px solid var(--focus-a8); } @keyframes slidein { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0px); opacity: 1; } } ::view-transition-new(menu) { animation-name: slidein; } ::view-transition-old(menu) { animation-name: slidein; animation-direction: reverse; }
-
-
-
@@ -1,82 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; import { Button, IconButton } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { ThirdPartyNoticeProvider } from "../../components/CopyrightNotice.ts"; import { Layout } from "./Layout.tsx"; export default { component: Layout, args: { title: "TITLE", children: "CHILDREN", workspace: create(WorkspaceSchema, { id: { value: "ws-foo", }, displayName: "Foo", }), }, parameters: { layout: "fullscreen", }, decorators: [ (Story) => ( <ThirdPartyNoticeProvider text="Foo"> <Story /> </ThirdPartyNoticeProvider> ), withInmemoryRouter(), ], } satisfies Meta<typeof Layout>; type Story = StoryObj<typeof Layout>; export const Defaults: Story = {}; export const MenuOpened: Story = { args: { defaultMenuOpened: true, }, }; export const WithTextAction: Story = { args: { actions: <Button>保存</Button>, }, }; export const WithIconActions: Story = { args: { actions: ( <> <IconButton variant="surface"> <TrashIcon /> </IconButton> <IconButton variant="surface"> <Pencil2Icon /> </IconButton> </> ), }, }; export const LongContents: Story = { args: { children: ( <ul> {Array.from({ length: 100 }, (_, i) => ( <li key={i}>Foo</li> ))} </ul> ), }, };
-
-
-
@@ -1,191 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { HamburgerMenuIcon } from "@radix-ui/react-icons"; import { Box, type BoxProps, Container, Flex, Grid, Heading, IconButton, ScrollArea, Text, } from "@radix-ui/themes"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, type ReactNode, use, useCallback, useState } from "react"; import * as CopyrightNotice from "../../components/CopyrightNotice.ts"; import { Logo } from "../../components/Logo.tsx"; import * as NavigationMenu from "../../components/NavigationMenu.ts"; import { URLContext } from "../../contexts/Router.tsx"; import { useViewTransition } from "../../hooks/useViewTransition.ts"; import * as calendar from "./calendar/page.tsx"; import * as paidLeaveProvisionTable from "./paid-leave-provision-table/page.tsx"; import * as summary from "./summary/page.tsx"; import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as workerDashboard from "./workers/:id/Dashboard.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import * as leaveDefinitionsNew from "./leave-definitions/new/page.tsx"; import css from "./Layout.module.css"; export interface LayoutProps extends Pick<BoxProps, "className" | "children" | "style"> { title: ReactNode; /** * この画面 (シーン) にグローバルな操作・アクション。 * Flex コンテナ内となるため、 Fragment で渡すとレイアウトされる。 */ actions?: ReactNode; defaultMenuOpened?: boolean; workspace: Workspace; worker?: Worker; } export const Layout: FC<LayoutProps> = ({ children, title, actions, defaultMenuOpened = false, workspace, worker, ...rest }) => { const viewTransition = useViewTransition(); const url = use(URLContext); const [isMenuOpened, setIsMenuOpened] = useState(defaultMenuOpened); const toggleMenu = useCallback(() => { viewTransition(() => { setIsMenuOpened((prev) => !prev); }); }, [viewTransition]); return ( <Grid {...rest} inset="0" height="100%" columns={{ initial: "1", md: "minmax(15rem, max-content) minmax(0, 1fr)" }} rows="max-content minmax(0, 1fr)" > <Flex className={css.appbar} display={{ initial: "none", md: "flex" }} align="center" justify="center" > <a className={css.logo} href="/"> <Logo /> </a> </Flex> <Flex className={css.appbar} p="2" align="center" gapX="3"> <Box display={{ md: "none" }}> <IconButton variant="soft" onClick={toggleMenu}> <HamburgerMenuIcon /> </IconButton> </Box> <Container size={{ initial: "4", md: "2", lg: "3" }}> <Flex align="center" minHeight="var(--space-6)"> <Box flexGrow="1" flexShrink="1"> <Heading size="3">{title}</Heading> </Box> {actions && ( <Flex justify="end" gapX="1"> {actions} </Flex> )} </Flex> </Container> </Flex> <Flex className={css.menu} p="2" gridRowStart="2" gridColumnStart="1" display={{ initial: isMenuOpened ? "flex" : "none", md: "flex" }} direction="column" gap="2" > <Box asChild flexGrow="1" flexShrink="1"> <ScrollArea> <NavigationMenu.Root> <NavigationMenu.Group title="労働者管理"> <NavigationMenu.Item current={calendar.pattern.test(url)}> <a href={calendar.href({ workspace })}> <calendar.Title /> </a> </NavigationMenu.Item> <NavigationMenu.Item current={summary.pattern.test(url)}> <a href={summary.href({ workspace })}> <summary.Title /> </a> </NavigationMenu.Item> <NavigationMenu.Item current={workers.pattern.test(url)}> <a href={`/${workspace.id?.value}/workers`}>労働者一覧</a> </NavigationMenu.Item> {workspace.workerAddKey && ( <NavigationMenu.Item current={workersNew.pattern.test(url)}> <a href={`/${workspace.id?.value}/workers/new`}>労働者登録</a> </NavigationMenu.Item> )} {worker && ( <NavigationMenu.Group title={worker.displayName || "労働者メニュー"}> <NavigationMenu.Item current={workerDashboard.pattern.test(url)}> <a href={workerDashboard.href({ workspace, worker })}> <workerDashboard.Title /> </a> </NavigationMenu.Item> </NavigationMenu.Group> )} </NavigationMenu.Group> <NavigationMenu.Group title="ワークスペース管理"> <NavigationMenu.Item current={leaveDefinitions.pattern.test(url)}> <a href={`/${workspace.id?.value}/leave-definitions`}> 休暇・休業種別一覧 </a> </NavigationMenu.Item> {workspace.createLeaveDefinitionKey && ( <NavigationMenu.Item current={leaveDefinitionsNew.pattern.test(url)}> <a href={leaveDefinitionsNew.createHref(workspace)}> <leaveDefinitionsNew.Title /> </a> </NavigationMenu.Item> )} <NavigationMenu.Item current={paidLeaveProvisionTable.pattern.test(url)}> <a href={paidLeaveProvisionTable.href({ workspace })}> <paidLeaveProvisionTable.Title /> </a> </NavigationMenu.Item> </NavigationMenu.Group> </NavigationMenu.Root> </ScrollArea> </Box> <Flex asChild align="center" justify="end" gap="4"> <Text size="1" color="gray"> <CopyrightNotice.ThirdParty /> <CopyrightNotice.FirstParty /> </Text> </Flex> </Flex> <Box overflowY="auto" position="relative" gridRowStart="2" gridColumnStart={{ initial: "1", md: "2" }} > {children} </Box> </Grid> ); };
-
-
-
@@ -1,127 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Button, type ButtonProps, DropdownMenu } from "@radix-ui/themes"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC } from "react"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { type Actions } from "./useActions.ts"; export interface MenuProps extends Pick<ButtonProps, "size" | "variant" | "color" | "className" | "style"> { actions: Actions; workspaceID: proto.MessageShape<typeof WorkspaceSchema>["id"]; disabled?: boolean; } export const Menu: FC<MenuProps> = ({ actions, workspaceID, disabled, ...rest }) => { const leaveDefinitions = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspaceID, readMask: { fields: [WorkspaceSchema.field.leaveDefinitions.number], leaveDefinitionsMask: { fields: [ LeaveSchema.field.id.number, LeaveSchema.field.revisions.number, LeaveSchema.field.displayName.number, ], }, }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.leaveDefinitions; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); return ( <DropdownMenu.Root> <DropdownMenu.Trigger> <Button {...rest} loading={actions.isPending}> 操作 <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> <DropdownMenu.Content> <DropdownMenu.Label>記録書き込み</DropdownMenu.Label> <DropdownMenu.Item disabled={disabled} shortcut="1" onSelect={() => void actions.markAsWorked()} > 出勤 </DropdownMenu.Item> <DropdownMenu.Item disabled={disabled} shortcut="2" onSelect={() => void actions.markAsDayOff()} > 休日 </DropdownMenu.Item> <DropdownMenu.Item disabled={disabled} shortcut="3" onSelect={() => void actions.markAsAbsent()} > 欠勤 </DropdownMenu.Item> <DropdownMenu.Item disabled={disabled} shortcut="4" onSelect={() => void actions.markAsPaidLeave()} > 年次有給休暇 </DropdownMenu.Item> <DropdownMenu.Sub> <DropdownMenu.SubTrigger>その他休暇休業</DropdownMenu.SubTrigger> <DropdownMenu.SubContent> {leaveDefinitions.data ? ( leaveDefinitions.data.map((def) => { if (!def.id) { return null; } return ( <DropdownMenu.Item key={def.id?.value} disabled={disabled} onSelect={() => void actions.markAsWorkspaceDefinedLeave(def)} > {def.displayName} </DropdownMenu.Item> ); }) ) : leaveDefinitions.isError ? ( <DropdownMenu.Item disabled>取得できませんでした</DropdownMenu.Item> ) : ( <DropdownMenu.Item disabled>取得中...</DropdownMenu.Item> )} </DropdownMenu.SubContent> </DropdownMenu.Sub> </DropdownMenu.Content> </DropdownMenu.Root> ); };
-
-
-
@@ -1,131 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Dialog } from "@radix-ui/themes"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { RecordEditDialog } from "./RecordEditDialog.ts"; export default { component: RecordEditDialog, args: { workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, abbreviations: { worked: "出勤", dayoff: "休日", skipWork: "欠勤", paidLeave: "年休", }, leaveDefinitions: [ { id: { value: "lv-foo" }, displayName: "産前産後休業", abbreviationName: "産休", currentRevision: { revisionId: { value: "lr-foo-foo" }, startAt: { year: 1947, month: 9, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, revisions: [ { revisionId: { value: "lr-foo-foo" }, startAt: { year: 1947, month: 9, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, { id: { value: "lv-baz" }, displayName: "リフレッシュ休暇", abbreviationName: "リ休", deletionKey: { key: new Uint8Array([]) }, revisions: [ { revisionId: { value: "lr-baz-foo" }, startAt: { year: 2010, month: 10, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, ], }, ], }), worker: proto.create(WorkerSchema, { writeWorkRecordKey: { key: new Uint8Array([0]), }, }), record: proto.create(WorkRecordSchema, { date: { year: 2025, month: 1, day: 1, }, kind: { case: "dayWhole", value: { kind: { case: "worked", value: {}, }, }, }, }), }, decorators: [ (Story) => ( <Dialog.Root open> <Story /> </Dialog.Root> ), ], } satisfies Meta<typeof RecordEditDialog>; type Story = StoryObj<typeof RecordEditDialog>; export const WorkedWholeDay: Story = {}; export const Readonly: Story = { args: { worker: proto.create(WorkerSchema), }, }; export const HalvedDay: Story = { args: { record: proto.create(WorkRecordSchema, { date: { year: 2025, month: 12, day: 10, }, kind: { case: "dayHalved", value: { am: { kind: { case: "worked", value: {}, }, }, pm: { kind: { case: "dayOff", value: {}, }, }, }, }, }), }, }; export const Empty: Story = { args: { record: proto.create(WorkRecordSchema), }, };
-
-
-
@@ -1,5 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export { RecordEditDialog } from "./RecordEditDialog/RecordEditDialog.tsx"; export type { RecordEditDialogProps } from "./RecordEditDialog/RecordEditDialog.tsx";
-
-
-
@@ -1,186 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { RecordKindWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_write_input_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { WorkRecordBatchWriteInputSchema } from "@yamori/proto/yamori/work_record/v1/work_record_batch_write_input_pb.js"; export type RecordType = | null | "worked" | "skipped" | "day_off" | "paid_leave" | `lv-${string}`; export function isRecordType(x: unknown): x is RecordType { switch (x) { case null: case "worked": case "skipped": case "day_off": case "paid_leave": return true; } if (typeof x !== "string") { return false; } return x.startsWith("lv-"); } export interface RecordFields { record: RecordType; hourly_paid_leave: number; } export interface FormValue { kind: "day_whole" | "day_halved"; day_whole: RecordFields; am: RecordFields; pm: RecordFields; note: string; } function recordKindToRecordFields( kind?: proto.MessageShape<typeof RecordKindSchema>, ): RecordFields { switch (kind?.kind.case) { case "worked": return { record: "worked", hourly_paid_leave: kind.kind.value.hourlyPaidLeave?.hours ?? 0, }; case "skipped": return { record: "skipped", hourly_paid_leave: kind.kind.value.hourlyPaidLeave?.hours ?? 0, }; case "dayOff": return { record: "day_off", hourly_paid_leave: 0, }; case "paidLeave": return { record: "paid_leave", hourly_paid_leave: 0, }; case "workspaceDefinedLeave": return { record: kind.kind.value.id?.value.startsWith("lv-") ? (kind.kind.value.id.value as `lv-${string}`) : null, hourly_paid_leave: 0, }; default: return { record: null, hourly_paid_leave: 0, }; } } export function fromWorkRecord( record: proto.MessageShape<typeof WorkRecordSchema>, ): FormValue { if (record.kind.case === "dayHalved") { return { kind: "day_halved", day_whole: recordKindToRecordFields(), am: recordKindToRecordFields(record.kind.value.am), pm: recordKindToRecordFields(record.kind.value.pm), note: record.note, }; } return { kind: "day_whole", day_whole: recordKindToRecordFields(record.kind.value), am: recordKindToRecordFields(), pm: recordKindToRecordFields(), note: record.note, }; } function recordFieldsToRecordKindWriteInput( fields: RecordFields, ): proto.MessageShape<typeof RecordKindWriteInputSchema> { switch (fields.record) { case null: return proto.create(RecordKindWriteInputSchema); case "skipped": case "worked": return proto.create(RecordKindWriteInputSchema, { kind: { case: fields.record === "skipped" ? "skipped" : "worked", value: { hourlyPaidLeave: fields.hourly_paid_leave > 0 ? { hours: fields.hourly_paid_leave, } : undefined, }, }, }); case "day_off": return proto.create(RecordKindWriteInputSchema, { kind: { case: "dayOff", value: {}, }, }); case "paid_leave": return proto.create(RecordKindWriteInputSchema, { kind: { case: "paidLeave", value: {}, }, }); default: return proto.create(RecordKindWriteInputSchema, { kind: { case: "workspaceDefinedLeaveId", value: { value: fields.record, }, }, }); } } export function toWorkRecordBatchWriteInput( date: proto.MessageShape<typeof DateSchema>, values: FormValue, ): proto.MessageShape<typeof WorkRecordBatchWriteInputSchema> { if (values.kind === "day_whole") { return proto.create(WorkRecordBatchWriteInputSchema, { dates: [date], kind: { case: "dayWhole", value: recordFieldsToRecordKindWriteInput(values.day_whole), }, note: values.note, }); } return proto.create(WorkRecordBatchWriteInputSchema, { dates: [date], kind: { case: "dayHalved", value: { am: recordFieldsToRecordKindWriteInput(values.am), pm: recordFieldsToRecordKindWriteInput(values.pm), }, }, note: values.note, }); }
-
-
-
@@ -1,188 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Button, Dialog, Flex, SegmentedControl, Text } from "@radix-ui/themes"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WriteWorkRecordRequestSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_request_pb.js"; import { WriteWorkRecordResponseSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_response_pb.js"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { type FC, useMemo } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useMethodMutation } from "../../../../contexts/Service.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { fromProtoDate } from "../../../../helpers.ts"; import { type FormValue, fromWorkRecord, toWorkRecordBatchWriteInput, } from "./FormValue.ts"; import { RecordKindFields } from "./RecordKindFields.tsx"; const dateFormatter = new Intl.DateTimeFormat("ja", { dateStyle: "medium", }); export interface RecordEditDialogProps { worker: proto.MessageShape<typeof WorkerSchema>; record: proto.MessageShape<typeof WorkRecordSchema>; workspace: proto.MessageShape<typeof WorkspaceSchema>; onSuccessfullyRecordEdited?(): void; } export const RecordEditDialog: FC<RecordEditDialogProps> = ({ worker, record, workspace, onSuccessfullyRecordEdited, }) => { const isReadonly = !worker.writeWorkRecordKey; const date = record.date && fromProtoDate(record.date); const toast = useToast(); const write = useMethodMutation({ service: "yamori.worker.v1.WorkerService", method: "WriteWorkRecord", request: { schema: WriteWorkRecordRequestSchema, }, response: { schema: WriteWorkRecordResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, options: { onSuccess() { onSuccessfullyRecordEdited?.(); }, onError(error) { if ( proto.isMessage(error, NotFoundSchema) && error.typeName === "yamori.worker.v1.PaidLeaveProvision" ) { toast.open({ severity: "warn", title: "年次有給休暇の残り日数がありません", }); return; } toast.open({ severity: "danger", title: "勤怠記録の更新に失敗しました", }); }, }, }); const defaultValues = useMemo(() => fromWorkRecord(record), [record]); const form = useForm<FormValue>({ defaultValues, mode: "onBlur", disabled: isReadonly, }); const kind = useWatch({ control: form.control, name: "kind", }); if (!date) { return null; } return ( <Dialog.Content asChild> <form onSubmit={form.handleSubmit(async (values) => { if (!record.date) { return; } await write.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: toWorkRecordBatchWriteInput(record.date, values), readMask: { fields: [], }, }); onSuccessfullyRecordEdited?.(); })} > <Dialog.Title>{dateFormatter.format(date)}</Dialog.Title> <Dialog.Description> {dateFormatter.format(date)} の勤怠履歴です。 </Dialog.Description> <Flex mt="3" justify="center" align="center"> <SegmentedControl.Root {...form.register("kind")} defaultValue={defaultValues.kind} onValueChange={(value) => { switch (value) { case "day_whole": case "day_halved": form.setValue("kind", value); return; } }} > <SegmentedControl.Item value="day_whole">全日</SegmentedControl.Item> <SegmentedControl.Item value="day_halved">半日</SegmentedControl.Item> </SegmentedControl.Root> </Flex> <Flex direction="column" gap="1" mt="2"> <FormProvider {...form}> {kind === "day_halved" ? ( <> <Text size="2" color="gray"> AM </Text> <RecordKindFields field="am" workspace={workspace} /> <Text mt="2" size="2" color="gray"> PM </Text> <RecordKindFields field="pm" workspace={workspace} /> </> ) : ( <RecordKindFields field="day_whole" workspace={workspace} /> )} </FormProvider> </Flex> <Flex mt="5" justify="end" align="center" gap="2"> {isReadonly ? ( <Dialog.Close> <Button>閉じる</Button> </Dialog.Close> ) : ( <> <Dialog.Close> <Button variant="outline">キャンセル</Button> </Dialog.Close> {!isReadonly && <Button>保存</Button>} </> )} </Flex> </form> </Dialog.Content> ); };
-
-
-
@@ -1,131 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Select } from "@radix-ui/themes"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { type FC } from "react"; import { Controller, useFormContext } from "react-hook-form"; import * as FormField from "../../../../components/FormField.ts"; import { RecordKindBadge } from "../../../../components/RecordKindBadge.tsx"; import { type FormValue, type RecordType, isRecordType } from "./FormValue.ts"; export interface RecordKindFieldsProps { field: keyof Pick<FormValue, "am" | "pm" | "day_whole">; workspace?: proto.MessageShape<typeof WorkspaceSchema>; } export const RecordKindFields: FC<RecordKindFieldsProps> = ({ field, workspace }) => { const form = useFormContext<FormValue>(); return ( <> <FormField.Root> <FormField.Label>勤怠記録</FormField.Label> <Controller control={form.control} name={`${field}.record`} rules={{ required: { value: true, message: "必須です", }, }} render={({ field: { disabled, name, value, onChange, ...field }, fieldState, }) => { return ( <> <Select.Root disabled={disabled} name={name} value={value ?? undefined} onValueChange={(value) => { if (isRecordType(value)) { onChange(value); } }} > <Select.Trigger {...field} /> <Select.Content> <Select.Item value={"worked" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "worked", value: {}, }, })} /> </Select.Item> <Select.Item value={"skipped" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "skipped", value: {}, }, })} /> </Select.Item> <Select.Item value={"day_off" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "dayOff", value: {}, }, })} /> </Select.Item> <Select.Item value={"paid_leave" satisfies RecordType}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "paidLeave", value: {}, }, })} /> </Select.Item> {workspace?.leaveDefinitions.map((leave) => { if (!leave.id) { return null; } return ( <Select.Item key={leave.id.value} value={leave.id.value}> <RecordKindBadge record={proto.create(RecordKindSchema, { kind: { case: "workspaceDefinedLeave", value: { ...leave, abbreviationName: "", }, }, })} /> </Select.Item> ); })} </Select.Content> </Select.Root> {fieldState.error?.message && ( <FormField.Description color="red"> {fieldState.error.message} </FormField.Description> )} </> ); }} /> </FormField.Root> </> ); };
-
-
-
@@ -1,142 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { DataList, Flex, Grid, Text } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { type FC, useState } from "react"; import * as Spreadsheet from "./Spreadsheet.ts"; const ROWS = 9; const COLUMNS = 9; const CellPosition: FC<{ row: number; column: number }> = ({ row, column }) => { const col = String.fromCharCode("A".charCodeAt(0) + column); return col + (row + 1); }; export default { component: Spreadsheet.Root, render() { const [selection, setSelection] = useState<Spreadsheet.Selection>(() => { return { cursor: null, expandingTo: null, committed: [], cells: [], }; }); return ( <Flex direction="column" gap="3"> <Spreadsheet.Root asChild selection={selection} rows={ROWS} columns={COLUMNS} onSelectionChange={setSelection} > <Grid columns="repeat(10, 2rem)"> <Spreadsheet.Row asChild row={-1}> <div style={{ display: "contents" }}> <Spreadsheet.ColumnHeader /> {Array.from({ length: COLUMNS }, (_, i) => { return ( <Spreadsheet.ColumnHeader key={i} asChild> <Text align="center" color="gray" weight="bold"> {String.fromCharCode("A".charCodeAt(0) + i)} </Text> </Spreadsheet.ColumnHeader> ); })} </div> </Spreadsheet.Row> {Array.from({ length: ROWS }, (_, row) => { return ( <Spreadsheet.Row key={row} row={row} asChild> <div style={{ display: "contents" }}> <Spreadsheet.RowHeader asChild> <Text align="center" color="gray" weight="bold"> {row + 1} </Text> </Spreadsheet.RowHeader> {Array.from({ length: COLUMNS }, (_, column) => { return ( <Spreadsheet.Cell key={column} column={column} asChild> <Text align="center" color={ selection.cursor && selection.cursor.row === row && selection.cursor.column === column ? "red" : selection.cells.some( (value) => value.row === row && value.column === column, ) ? "pink" : undefined } > <CellPosition row={row} column={column} /> </Text> </Spreadsheet.Cell> ); })} </div> </Spreadsheet.Row> ); })} </Grid> </Spreadsheet.Root> <DataList.Root> <DataList.Item> <DataList.Label>Cursor</DataList.Label> <DataList.Value> {selection.cursor ? <CellPosition {...selection.cursor} /> : "---"} </DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>Expanding to</DataList.Label> <DataList.Value> {selection.expandingTo ? ( <CellPosition {...selection.expandingTo} /> ) : ( "---" )} </DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>Committed</DataList.Label> <DataList.Value> <Flex wrap="wrap" gap="2"> {selection.committed.map((cell) => ( <Text key={cell.row + "-" + cell.column}> <CellPosition {...cell} /> </Text> ))} </Flex> </DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>Final selected cells</DataList.Label> <DataList.Value> <Flex wrap="wrap" gap="2"> {selection.cells.map((cell) => ( <Text key={cell.row + "-" + cell.column}> <CellPosition {...cell} /> </Text> ))} </Flex> </DataList.Value> </DataList.Item> </DataList.Root> </Flex> ); }, } satisfies Meta<(typeof Spreadsheet)["Root"]>; type Story = StoryObj<(typeof Spreadsheet)["Root"]>; export const Demo: Story = {};
-
-
-
@@ -1,10 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export * from "./Spreadsheet/Root.tsx"; export * from "./Spreadsheet/Cell.tsx"; export * from "./Spreadsheet/Row.tsx"; export * from "./Spreadsheet/RowHeader.tsx"; export * from "./Spreadsheet/ColumnHeader.tsx"; export { empty } from "./Spreadsheet/helpers.ts"; export type { GridCell, Selection } from "./Spreadsheet/types.ts";
-
-
-
@@ -1,186 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Slot } from "@radix-ui/themes"; import { type FC, type ReactNode, use, useCallback } from "react"; import { GridSizeContext } from "./GridSizeContext.ts"; import { RowContext } from "./RowContext.ts"; import { UpdateSelectionContext } from "./UpdateSelectionContext.ts"; import { SelectionContext } from "./SelectionContext.ts"; import { getCellsForRange } from "./helpers.ts"; import type { GridCell, GridSize } from "./types.ts"; function moveCellByKeyboard( { row, column }: GridCell, size: GridSize, event: KeyboardEvent, ): GridCell { const prevColumn = column === 0 ? size.columns - 1 : column - 1; const nextColumn = column === size.columns - 1 ? 0 : column + 1; const isJumpKeyPressed = event.ctrlKey || event.metaKey; switch (event.key) { case "Tab": return { row, column: event.shiftKey ? prevColumn : nextColumn, }; case "ArrowLeft": return { row, column: isJumpKeyPressed ? 0 : prevColumn, }; case "ArrowRight": return { row, column: isJumpKeyPressed ? size.columns - 1 : nextColumn, }; case "ArrowDown": return { row: isJumpKeyPressed ? size.rows - 1 : row === size.rows - 1 ? 0 : row + 1, column, }; case "ArrowUp": return { row: isJumpKeyPressed ? 0 : row === 0 ? size.rows - 1 : row - 1, column, }; default: return { row, column }; } } export interface CellProps { className?: string; children: ReactNode; asChild?: boolean; column: number; } export const Cell: FC<CellProps> = ({ asChild, column, ...props }) => { const updater = use(UpdateSelectionContext); const row = use(RowContext); const size = use(GridSizeContext); const selection = use(SelectionContext); const Component = asChild ? Slot : "div"; const moveTo = useCallback( (row: number, column: number) => { updater(() => { return { cursor: { row, column }, expandingTo: null, committed: [], }; }); }, [updater], ); return ( <Component role="gridcell" tabIndex={0} aria-selected={selection.cells.some( (cell) => cell.row === row && cell.column === column, )} data-selection-start={ selection.cursor && selection.cursor.row === row && selection.cursor.column === column ? "" : undefined } data-gridrow={row} data-gridcol={column} style={{ userSelect: "none" }} onClick={(event) => { event.preventDefault(); if (event.shiftKey) { updater((prev) => { return { ...prev, expandingTo: { row, column }, }; }); return; } if (event.ctrlKey || event.metaKey) { updater((prev) => { if (!prev.cursor) { return { ...prev, cursor: { row, column }, expandingTo: null, }; } return { cursor: { row, column }, expandingTo: null, committed: [ ...prev.committed, ...getCellsForRange(prev.cursor, prev.expandingTo || prev.cursor), ], }; }); return; } moveTo(row, column); }} onKeyDown={(event) => { switch (event.key) { case "Tab": case "ArrowDown": case "ArrowUp": case "ArrowRight": case "ArrowLeft": { event.preventDefault(); let next: GridCell; if (event.key !== "Tab" && event.shiftKey) { next = moveCellByKeyboard( selection.expandingTo || { row, column }, size, event.nativeEvent, ); updater((prev) => { return { ...prev, expandingTo: next, }; }); } else { next = moveCellByKeyboard( selection.cursor || { row, column }, size, event.nativeEvent, ); moveTo(next.row, next.column); } const nextEl = document.querySelector( `[data-gridrow="${next.row}"][data-gridcol="${next.column}"]`, ); if (nextEl instanceof HTMLElement) { nextEl.focus(); } return; } } }} {...props} /> ); };
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Slot } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface ColumnHeaderProps { className?: string; children?: ReactNode; asChild?: boolean; } export const ColumnHeader: FC<ColumnHeaderProps> = ({ asChild, ...props }) => { const Component = asChild ? Slot : "div"; return <Component role="columnheader" style={{ userSelect: "none" }} {...props} />; };
-
-
-
@@ -1,11 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createContext } from "react"; import type { GridSize } from "./types.ts"; export const GridSizeContext = createContext<GridSize>({ rows: 0, columns: 0, });
-
-
-
@@ -1,64 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Slot } from "@radix-ui/themes"; import { type FC, type ReactNode, useCallback, useMemo } from "react"; import { UpdateSelection, UpdateSelectionContext } from "./UpdateSelectionContext.ts"; import { GridSizeContext } from "./GridSizeContext.ts"; import { SelectionContext } from "./SelectionContext.ts"; import { getSelectedCells } from "./helpers.ts"; import type { GridSize, Selection } from "./types.ts"; export interface RootProps extends GridSize { children: ReactNode; asChild?: boolean; selection: Selection; onSelectionChange?(selection: Selection): void; } export const Root: FC<RootProps> = ({ asChild, children, rows, columns, selection, onSelectionChange, }) => { const Component = asChild ? Slot : "div"; const updater = useCallback<UpdateSelection>( (callback) => { if (!onSelectionChange) { return; } const next = callback(selection); onSelectionChange?.({ ...next, cells: getSelectedCells(next), }); }, [selection, onSelectionChange], ); const size = useMemo<GridSize>(() => { return { rows, columns }; }, [rows, columns]); return ( <SelectionContext.Provider value={selection}> <GridSizeContext.Provider value={size}> <UpdateSelectionContext.Provider value={updater}> <Component role="grid" aria-multiselectable> {children} </Component> </UpdateSelectionContext.Provider> </GridSizeContext.Provider> </SelectionContext.Provider> ); };
-
-
-
@@ -1,27 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Slot } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; import { RowContext } from "./RowContext.ts"; export interface RowProps { className?: string; children: ReactNode; asChild?: boolean; row: number; } export const Row: FC<RowProps> = ({ asChild, row, ...props }) => { const Component = asChild ? Slot : "div"; return ( <RowContext.Provider value={row}> <Component role="row" {...props} /> </RowContext.Provider> ); };
-
-
-
@@ -1,6 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createContext } from "react"; export const RowContext = createContext<number>(NaN);
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Slot } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface RowHeaderProps { className?: string; children: ReactNode; asChild?: boolean; } export const RowHeader: FC<RowHeaderProps> = ({ asChild, ...props }) => { const Component = asChild ? Slot : "div"; return <Component role="rowheader" style={{ userSelect: "none" }} {...props} />; };
-
-
-
@@ -1,13 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createContext } from "react"; import type { Selection } from "./types"; export const SelectionContext = createContext<Selection>({ cursor: null, expandingTo: null, committed: [], cells: [], });
-
-
-
@@ -1,12 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { createContext } from "react"; import type { Selection } from "./types.ts"; export type UpdateSelection = ( updater: (prev: Selection) => Omit<Selection, "cells">, ) => void; export const UpdateSelectionContext = createContext<UpdateSelection>(() => void 0);
-
-
-
@@ -1,49 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { GridCell, Selection } from "./types.ts"; export function getCellsForRange(from: GridCell, to: GridCell): readonly GridCell[] { const rowStart = Math.min(from.row, to.row); const rowEnd = Math.max(from.row, to.row); const colStart = Math.min(from.column, to.column); const colEnd = Math.max(from.column, to.column); const rows = rowEnd - rowStart + 1; const cols = colEnd - colStart + 1; return Array.from({ length: rows }, (_r, rowOffset) => { return Array.from({ length: cols }, (_c, colOffset) => { return { row: rowStart + rowOffset, column: colStart + colOffset, }; }); }).flat(); } export function getSelectedCells({ cursor, expandingTo, committed, }: Omit<Selection, "cells">): readonly GridCell[] { if (!cursor) { return committed; } const range = expandingTo ? getCellsForRange(cursor, expandingTo) : [cursor]; return [...range, ...committed].filter( (cell, i, cells) => cells.findIndex((c) => c.row === cell.row && c.column === cell.column) === i, ); } export function empty(): Selection { return { cursor: null, expandingTo: null, committed: [], cells: [], }; }
-
-
-
@@ -1,19 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export interface GridCell { row: number; column: number; } export interface GridSize { rows: number; columns: number; } export interface Selection { cursor: GridCell | null; expandingTo: GridCell | null; committed: readonly GridCell[]; cells: readonly GridCell[]; }
-
-
-
@@ -1,61 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .row { display: contents; } .rowHeaderCell { border-bottom: 1px solid var(--gray-a3); } .columnHeaderCell { border-right: 1px solid var(--gray-a3); } .writableCell { border: none; background-color: transparent; outline: none; } .recordCell { border-bottom: 1px solid var(--gray-a2); border-right: 1px solid var(--gray-a2); } .writableCell { position: relative; } .writableCell::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; left: 0; border-radius: var(--radius-1); outline-offset: -2px; outline-width: 2px; outline-style: solid; outline-color: transparent; pointer-events: none; } @media (any-hover: hover) { .writableCell:hover { background-color: var(--gray-a2); } } .writableCell[aria-selected="true"] { background-color: var(--accent-a2); } .writableCell[data-selection-start]::after { outline-color: var(--accent-7); }
-
-
-
@@ -1,43 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { List } from "../../../mocks/yamori/worker/v1/worker_service.ts"; import * as workspaceService from "../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page } from "./page.tsx"; const workspace = proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/calendar" }), withMockedBackend([workspaceService.Get(), List()]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = {}; export const MonthInURL: Story = { decorators: [withInmemoryRouter({ initialURL: "/ws-foo/calendar?month=2020-1" })], }; export const InvalidMonth: Story = { decorators: [withInmemoryRouter({ initialURL: "/ws-foo/calendar?month=2020" })], };
-
-
-
@@ -1,564 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import * as proto from "@bufbuild/protobuf"; import { tz } from "@date-fns/tz"; import { CaretLeftIcon, CaretRightIcon, PersonIcon } from "@radix-ui/react-icons"; import { Avatar, Box, Container, ContextMenu, Dialog, Flex, Grid, IconButton, Link, ScrollArea, Text, VisuallyHidden, } from "@radix-ui/themes"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, } from "date-fns"; import { type FC, use, useEffect, useMemo, useRef, useState } from "react"; import { WorkRecordBadges } from "../../../components/WorkRecordBadges.tsx"; import { NavigationContext, URLContext } from "../../../contexts/Router.tsx"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { fromProtoDate, toProtoDate, isSameDate } from "../../../helpers.ts"; import * as workerDashboard from "../workers/:id/Dashboard.tsx"; import { Layout } from "../Layout.tsx"; import { Menu } from "./Menu.tsx"; import { RecordEditDialog, type RecordEditDialogProps } from "./RecordEditDialog.ts"; import * as Spreadsheet from "./Spreadsheet.ts"; import { useActions } from "./useActions.ts"; import css from "./page.module.css"; export const Title: FC = () => "カレンダー"; function addMonthToSearchParams( month: proto.MessageShape<typeof DateSchema>, base?: URLSearchParams, ): URLSearchParams { const params = new URLSearchParams(base); params.set("month", `${month.year}-${month.month}`); return params; } export interface HrefInput { workspace: proto.MessageShape<typeof WorkspaceSchema>; month?: proto.MessageShape<typeof DateSchema>; } export function href({ workspace, month }: HrefInput): string { if (!workspace.id) { return "/"; } const base = `/${workspace.id.value}/calendar`; if (!month) { return base; } return base + "?" + addMonthToSearchParams(month).toString(); } export const pattern = new URLPattern({ pathname: "/:workspace/calendar", }); // TODO: どこか共通のファイルで定義する const TZ = "Asia/Tokyo"; const weekdayFormatter = new Intl.DateTimeFormat(navigator.language, { weekday: "short", }); interface BodyProps { workspace: proto.MessageShape<typeof WorkspaceSchema>; month: proto.MessageShape<typeof DateSchema>; onChangeMonth(month: proto.MessageShape<typeof DateSchema>): void; } const Body: FC<BodyProps> = ({ workspace, month, onChangeMonth }) => { const { start, end } = useMemo(() => { const d = fromProtoDate(month); return { start: toProtoDate(startOfMonth(d, { in: tz(TZ) })), end: toProtoDate(endOfMonth(d, { in: tz(TZ) })), } as const; }, [month]); const wsDetails = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, readMask: { fields: [ WorkspaceSchema.field.abbreviations.number, WorkspaceSchema.field.leaveDefinitions.number, WorkspaceSchema.field.id.number, ], }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); const query = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspace.id, workRecordFilter: { since: start, until: end, }, paidLeaveProvisionFilter: { providedAtUntil: end, expiresAtSince: start, }, readMask: { fields: [ WorkerSchema.field.id.number, WorkerSchema.field.displayName.number, WorkerSchema.field.writeWorkRecordKey.number, WorkerSchema.field.providePaidLeaveKey.number, WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], paidLeaveProvisionsMask: { fields: [ PaidLeaveProvisionSchema.field.providedAt.number, PaidLeaveProvisionSchema.field.expiresAt.number, PaidLeaveProvisionSchema.field.remainingDays.number, PaidLeaveProvisionSchema.field.isHalvedDayRemaining.number, ], }, workRecordsMask: { fields: [ WorkRecordSchema.field.date.number, WorkRecordSchema.field.dayWhole.number, WorkRecordSchema.field.dayHalved.number, ], }, }, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.workers; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); const dates = useMemo(() => { return eachDayOfInterval( { start: fromProtoDate(start), end: fromProtoDate(end), }, { in: tz(TZ), }, ); }, [start, end]); const [selection, setSelection] = useState(() => Spreadsheet.empty()); // 見えていない別の月の選択状態を保持してもわかりづらいので、表示月が // 変わったタイミングで選択を解除する。 useEffect(() => { setSelection(Spreadsheet.empty()); }, [month]); const scrollAreaRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!scrollAreaRef.current) { return; } scrollAreaRef.current.scrollLeft = 0; }, [month]); const actions = useActions({ workspace, workers: query.data || [], dates, selection, onWorkRecordUpdated() { query.refetch(); }, }); const actionsRef = useRef(actions); actionsRef.current = actions; const rows = query.data?.length ?? 0; const columns = dates.length; useEffect(() => { const listener = async (event: KeyboardEvent) => { if (event.isComposing || event.ctrlKey || event.shiftKey || event.metaKey) { return; } let action: () => Promise<void>; switch (event.key) { case "1": action = actionsRef.current.markAsWorked; break; case "2": action = actionsRef.current.markAsDayOff; break; case "3": action = actionsRef.current.markAsAbsent; break; case "4": action = actionsRef.current.markAsPaidLeave; break; default: return; } event.preventDefault(); event.stopPropagation(); await action(); // TODO: スプレッドシート側で次に送る動作を定義する setSelection((prev) => { if (!prev.cursor) { return prev; } const next: Spreadsheet.GridCell = prev.cursor.column === columns - 1 ? { row: prev.cursor.row === rows - 1 ? 0 : prev.cursor.row + 1, column: 0, } : { row: prev.cursor.row, column: prev.cursor.column + 1, }; // TODO: 外に出す const nextEl = document.querySelector( `[data-gridrow="${next.row}"][data-gridcol="${next.column}"]`, ); if (nextEl instanceof HTMLElement) { nextEl.focus(); } return { cells: [next], cursor: next, expandingTo: null, committed: [], }; }); }; document.addEventListener("keyup", listener); return () => void document.removeEventListener("keyup", listener); }, [rows, columns]); const [editDialogPayload, setEditDialogPayload] = useState<Pick< RecordEditDialogProps, "worker" | "record" > | null>(null); return ( <Dialog.Root open={!!editDialogPayload} onOpenChange={(isOpen) => { if (!isOpen) { setEditDialogPayload(null); } }} > <Container px="2" py="4" size="2"> <Flex align="center" gap="1"> <IconButton variant="soft" size="2" onClick={() => { onChangeMonth(toProtoDate(subMonths(fromProtoDate(month), 1))); }} > <CaretLeftIcon /> <VisuallyHidden>前の月へ</VisuallyHidden> </IconButton> <Text weight="bold" size="2" align="center" style={{ minWidth: "6rem" }}> {month.year}年{month.month}月 </Text> <IconButton variant="soft" size="2" onClick={() => { onChangeMonth(toProtoDate(addMonths(fromProtoDate(month), 1))); }} > <CaretRightIcon /> <VisuallyHidden>次の月へ</VisuallyHidden> </IconButton> <Box flexGrow="1" flexShrink="1" /> <Menu disabled={selection.cells.length === 0} actions={actions} workspaceID={workspace.id} /> </Flex> </Container> <Box mt="2" pl={{ md: "2" }}> <ScrollArea ref={scrollAreaRef} scrollbars="horizontal" size="2"> <Spreadsheet.Root asChild selection={selection} onSelectionChange={setSelection} rows={rows} columns={columns} > <Grid columns={`max-content repeat(${dates.length}, 3rem)`} pb="4"> <Spreadsheet.Row className={css.row} row={-1}> <Spreadsheet.ColumnHeader asChild> <Box className={`${css.rowHeaderCell} ${css.columnHeaderCell}`} position="sticky" left="0" top="0" style={{ backgroundColor: "var(--color-background)", zIndex: 5 }} /> </Spreadsheet.ColumnHeader> {dates.map((d) => ( <Spreadsheet.ColumnHeader key={+d} asChild> <Flex className={css.rowHeaderCell} position="sticky" top="0" direction="column" align="center" justify="center" p="1" style={{ zIndex: 1 }} > <Text size="1" weight="bold" color={ d.getDay() === 0 ? "red" : d.getDay() === 6 ? "blue" : "gray" } > {weekdayFormatter.format(d)} </Text> <Text size="3" weight="bold"> {d.getDate()} </Text> </Flex> </Spreadsheet.ColumnHeader> ))} </Spreadsheet.Row> {query.data?.map((worker, workerIndex) => { return ( <Spreadsheet.Row key={worker.id?.value} className={css.row} row={workerIndex} > <Spreadsheet.RowHeader asChild> <Flex asChild className={css.columnHeaderCell} position="sticky" left="0" style={{ backgroundColor: "var(--color-background)", zIndex: 3 }} align="center" gap="2" p="2" > <Link href={workerDashboard.href({ workspace, worker })}> <Avatar size="2" fallback={worker.displayName[0] || <PersonIcon />} /> <Text>{worker.displayName}</Text> </Link> </Flex> </Spreadsheet.RowHeader> {dates.map((date, dateIndex) => { const d = toProtoDate(date); const record = worker.workRecords.find( (record) => record.date && isSameDate(record.date, d), ); const contents = record && ( <WorkRecordBadges abbreviations={wsDetails.data?.abbreviations} workRecord={record} /> ); return ( <ContextMenu.Root key={+date}> <ContextMenu.Trigger> <Spreadsheet.Cell asChild column={dateIndex}> <Flex className={`${css.recordCell} ${css.writableCell}`} direction="column" gap="1" p="1" minWidth="0" > {contents} </Flex> </Spreadsheet.Cell> </ContextMenu.Trigger> <ContextMenu.Content> <ContextMenu.Item onSelect={() => { setEditDialogPayload({ worker, record: record || proto.create(WorkRecordSchema, { date: d, }), }); }} > 詳細 </ContextMenu.Item> </ContextMenu.Content> </ContextMenu.Root> ); })} </Spreadsheet.Row> ); })} </Grid> </Spreadsheet.Root> </ScrollArea> </Box> {wsDetails.data && editDialogPayload && ( <RecordEditDialog workspace={wsDetails.data} {...editDialogPayload} onSuccessfullyRecordEdited={() => { query.refetch(); setEditDialogPayload(null); }} /> )} </Dialog.Root> ); }; export interface PageProps { workspace: proto.MessageShape<typeof WorkspaceSchema>; } export const Page: FC<PageProps> = ({ workspace }) => { const navigation = use(NavigationContext); const url = use(URLContext); const searchParams = useMemo(() => new URLSearchParams(url.search), [url.search]); const month = useMemo<proto.MessageShape<typeof DateSchema>>(() => { const param = searchParams.get("month"); if (!param) { return toProtoDate(Date.now()); } const tokens = param.split("-").map((s) => parseInt(s, 10)); if (tokens.length !== 2 || tokens.some((t) => !Number.isFinite(t))) { return toProtoDate(Date.now()); } return proto.create(DateSchema, { year: tokens[0], month: tokens[1], day: 1, }); }, [searchParams]); // ブラウザのクエリパラメータと month が違う場合は同期させる useEffect(() => { const next = addMonthToSearchParams(month, searchParams); if (next.toString() === searchParams.toString()) { return; } navigation.replace(href({ workspace, month })); }, [searchParams, month]); return ( <Layout workspace={workspace} title={<Title />}> <Body workspace={workspace} month={month} onChangeMonth={(month) => { navigation.replace( href({ workspace, month, }), ); }} /> </Layout> ); };
-
-
-
@@ -1,274 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { WriteWorkRecordRequestSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_request_pb.js"; import { WriteWorkRecordResponseSchema } from "@yamori/proto/yamori/worker/v1/write_work_record_response_pb.js"; import { isBefore } from "date-fns"; import { useMethodMutation } from "../../../contexts/Service.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { fromProtoDate, toProtoDate } from "../../../helpers.ts"; import type * as Spreadsheet from "./Spreadsheet.ts"; export interface Actions { isPending: boolean; /** * 選択されたセルを出勤として設定する。 */ markAsWorked(): Promise<void>; /** * 選択されたセルを欠勤として設定する。 */ markAsAbsent(): Promise<void>; /** * 選択されたセルを休日として設定する。 */ markAsDayOff(): Promise<void>; /** * 選択されたセルを法定・特別休暇として設定する。 */ markAsWorkspaceDefinedLeave( leave: proto.MessageShape<typeof LeaveSchema>, ): Promise<void>; /** * 選択されたセルを年次有給休暇として設定する。 */ markAsPaidLeave(): Promise<void>; } export interface UseActionsInput { workspace: proto.MessageShape<typeof WorkspaceSchema>; selection: Spreadsheet.Selection; workers: readonly proto.MessageShape<typeof WorkerSchema>[]; dates: readonly Date[]; onWorkRecordUpdated?(): void; } export function useActions({ workspace, selection, workers, dates, onWorkRecordUpdated, }: UseActionsInput): Actions { const toast = useToast(); const mutation = useMethodMutation({ service: "yamori.worker.v1.WorkerService", method: "WriteWorkRecord", request: { schema: WriteWorkRecordRequestSchema, }, response: { schema: WriteWorkRecordResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { return resp.result.value.workRecords; } if (typeof resp.result.case === "undefined") { throw new IllegalMessageError(resp); } throw resp.result.value; }, options: { onError(error) { if ( proto.isMessage(error, NotFoundSchema) && error.typeName === "yamori.worker.v1.PaidLeaveProvision" ) { toast.open({ severity: "warn", title: "年次有給休暇の残り日数がありません", }); return; } toast.open({ severity: "danger", title: "勤怠記録の更新に失敗しました", }); }, }, }); const runForWorkers = async ( callback: ( worker: proto.MessageShape<typeof WorkerSchema>, dates: proto.MessageShape<typeof DateSchema>[], ) => Promise<void>, ) => { try { for (let i = 0, l = workers.length; i < l; i++) { const cells = selection.cells .filter((cell) => cell.row === i) .map((cell) => { const date = dates[cell.column]; if (!date) { return null; } return toProtoDate(date); }) .filter((d): d is proto.MessageShape<typeof DateSchema> => !!d); const worker = workers[i]; if (!worker || !cells.length) { continue; } if (!worker.writeWorkRecordKey) { toast.open({ severity: "warn", title: "書き込み権限がありません", description: `労働者「${worker.displayName}」への書き込み権限がありません。`, }); continue; } await callback(worker, cells); } } finally { onWorkRecordUpdated?.(); } }; return { isPending: mutation.isPending, markAsDayOff() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, record: { case: "dayOff", value: {}, }, }, }); }); }, markAsWorked() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, kind: { case: "dayWhole", value: { kind: { case: "worked", value: {}, }, }, }, }, }); }); }, markAsAbsent() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, kind: { case: "dayWhole", value: { kind: { case: "skipped", value: {}, }, }, }, }, }); }); }, markAsPaidLeave() { return runForWorkers(async (worker, dates) => { await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, kind: { case: "dayWhole", value: { kind: { case: "paidLeave", value: {}, }, }, }, }, }); }); }, markAsWorkspaceDefinedLeave(leave) { return runForWorkers(async (worker, dates) => { const firstAvailableAt = leave.revisions[0]?.startAt && fromProtoDate(leave.revisions[0].startAt); const datesTheLeaveNotAvailableFor = firstAvailableAt ? dates.filter((date) => isBefore(fromProtoDate(date), firstAvailableAt)) : []; if (firstAvailableAt && datesTheLeaveNotAvailableFor.length > 0) { toast.open({ severity: "warn", title: `${leave.displayName}は利用できません`, description: `対象範囲に${leave.displayName}の運用開始日以前の日付が含まれているため${worker.displayName}への書き込みをスキップします。`, }); return; } await mutation.mutateAsync({ workspaceId: workspace.id, workerId: worker.id, writeWorkRecordKey: worker.writeWorkRecordKey, workRecord: { dates, kind: { case: "dayWhole", value: { kind: { case: "workspaceDefinedLeaveId", value: leave.id!, }, }, }, }, }); }); }, }; }
-
-
-
@@ -1,222 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { CardStackPlusIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; import { AlertDialog, Badge, Box, Button, Code, DataList, DropdownMenu, Flex, IconButton, Select, Text, } from "@radix-ui/themes"; import { type Leave } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { type LeaveRevision } from "@yamori/proto/yamori/work_record/v1/leave_revision_pb.js"; import { type Date } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type FC, type ReactNode, useState } from "react"; import * as HelpDialog from "../../../components/HelpDialog.ts"; interface DateDisplayProps { date: Date; } const DateDisplay: FC<DateDisplayProps> = ({ date }) => { return [ date.year > 0 && date.year.toString(), date.month > 0 && date.month.toString(), date.day > 0 && date.day.toString(), ] .filter((x): x is string => typeof x === "string") .join("/"); }; export interface ListItemProps { leaveDefinition: Leave; pageTitle: ReactNode; pending?: boolean; onDelete?(def: Leave): void; } export const ListItem: FC<ListItemProps> = ({ leaveDefinition, pending = false, pageTitle, onDelete, }) => { const [revision, setRevision] = useState<LeaveRevision | null>( () => leaveDefinition.currentRevision ?? leaveDefinition.revisions[0] ?? null, ); return ( <Flex role="listitem"> <Box asChild flexGrow="1" flexShrink="1"> <DataList.Root orientation="vertical"> <DataList.Item> <DataList.Label>種別名</DataList.Label> <DataList.Value>{leaveDefinition.displayName}</DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>省略表記</DataList.Label> <DataList.Value>{leaveDefinition.abbreviationName}</DataList.Value> </DataList.Item> {leaveDefinition.revisions.length > 0 && ( <DataList.Item> <DataList.Label>バージョン</DataList.Label> <DataList.Value> <Select.Root defaultValue={revision?.revisionId?.value} onValueChange={(id) => { const match = leaveDefinition.revisions.find( (rev) => rev.revisionId?.value === id, ); if (match) { setRevision(match); } }} > <Select.Trigger /> <Select.Content> {leaveDefinition.revisions.map((rev) => { if (!rev.revisionId || !rev.startAt) { return null; } return ( <Select.Item key={rev.revisionId.value} value={rev.revisionId.value} > <DateDisplay date={rev.startAt} />~ </Select.Item> ); })} </Select.Content> </Select.Root> </DataList.Value> </DataList.Item> )} {revision && ( <DataList.Item> <DataList.Label>年次有給休暇付与の出勤率計算時の扱い</DataList.Label> <DataList.Value> <Flex mt="1" align="center" gap="1"> {revision.snapshot?.isWorkerDeemedToBeWorked ? ( <HelpDialog.Root> <Badge color="grass"> <CardStackPlusIcon /> 出勤扱い </Badge> <HelpDialog.Trigger>出勤扱い休暇休業の説明</HelpDialog.Trigger> <HelpDialog.Content> <HelpDialog.Title>出勤扱いの休暇・休業</HelpDialog.Title> <HelpDialog.Description> 年次有給休暇を付与する際の出勤率計算時、この休暇・休業を行った日は出勤したものとして扱います。 </HelpDialog.Description> <HelpDialog.Paragraph> 79 日出勤した所定労働日 100 日の労働者が {leaveDefinition.displayName ? `「${leaveDefinition.displayName}」` : "この休暇・休業"} を行った場合、その時点での出勤率は{" "} <Code>80出勤日 / 100所定労働日 = 80%</Code> となります。 </HelpDialog.Paragraph> </HelpDialog.Content> </HelpDialog.Root> ) : ( <HelpDialog.Root> <Badge color="gray">出勤率計上なし</Badge> <HelpDialog.Trigger> 出勤率計上なしの休暇休業の説明 </HelpDialog.Trigger> <HelpDialog.Content> <HelpDialog.Title>出勤率計上なしの休暇・休業</HelpDialog.Title> <HelpDialog.Description> 年次有給休暇を付与する際の出勤率計算時、この休暇・休業を行った日は欠勤と同様に扱います。 </HelpDialog.Description> <HelpDialog.Paragraph> 79 日出勤した所定労働日 100 日の労働者が {leaveDefinition.displayName ? `「${leaveDefinition.displayName}」` : "この休暇・休業"} を行った場合、その時点での出勤率は{" "} <Code>79出勤日 / 100所定労働日 = 79%</Code> となります。 </HelpDialog.Paragraph> <HelpDialog.Paragraph size="2" color="gray"> このバッジは{pageTitle}以外では表示されません。 </HelpDialog.Paragraph> </HelpDialog.Content> </HelpDialog.Root> )} </Flex> </DataList.Value> </DataList.Item> )} </DataList.Root> </Box> <AlertDialog.Root open={ // AlertDialog が disabled な DropdownMenu.Item / AlertDialog.Trigger の // onSelect を拾う Radix UI のバグがあるため上流で無理やり止める。 leaveDefinition.deletionKey ? undefined : false } > <DropdownMenu.Root> <DropdownMenu.Trigger> <IconButton disabled={pending} variant="ghost" color="gray" m="1"> <DotsHorizontalIcon /> </IconButton> </DropdownMenu.Trigger> <DropdownMenu.Content> <DropdownMenu.Item disabled>表示名の編集</DropdownMenu.Item> <DropdownMenu.Sub> <DropdownMenu.SubTrigger>バージョン管理</DropdownMenu.SubTrigger> <DropdownMenu.SubContent> <DropdownMenu.Item disabled>このバージョンを編集</DropdownMenu.Item> <DropdownMenu.Item disabled>このバージョンを削除</DropdownMenu.Item> <DropdownMenu.Item disabled>新規バージョンを作成</DropdownMenu.Item> </DropdownMenu.SubContent> </DropdownMenu.Sub> <DropdownMenu.Separator /> <AlertDialog.Trigger disabled={!leaveDefinition.deletionKey}> <DropdownMenu.Item color="red">削除</DropdownMenu.Item> </AlertDialog.Trigger> </DropdownMenu.Content> </DropdownMenu.Root> <AlertDialog.Content maxWidth="30rem"> <AlertDialog.Title>休暇・休業種別の削除</AlertDialog.Title> <AlertDialog.Description size="2"> 「{leaveDefinition.displayName}」を削除しようとしています。 一度削除した種別は戻せません。 </AlertDialog.Description> <Text as="p" mt="4" size="2"> 本当に削除してよろしいですか? </Text> <Flex mt="5" justify="end" gap="3"> <AlertDialog.Cancel> <Button variant="soft" color="gray"> キャンセル </Button> </AlertDialog.Cancel> <AlertDialog.Action> <Button color="red" onClick={() => void onDelete?.(leaveDefinition)}> 削除 </Button> </AlertDialog.Action> </Flex> </AlertDialog.Content> </AlertDialog.Root> </Flex> ); };
-
-
-
@@ -1,108 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { CreateLeaveDefinition } from "../../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, createLeaveDefinitionKey: { key: new Uint8Array([]) }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/leave-definitions/new" }), withMockedBackend([CreateLeaveDefinition()]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const WithoutCapability: Story = { args: { workspace: create(WorkspaceSchema, { id: { value: "ws-foo" }, }), }, }; export const Success: Story = {}; export const SlowLoad: Story = { decorators: [withMockedBackend([CreateLeaveDefinition({ delayMs: 2_000 })])], }; export const SystemError: Story = { decorators: [ withMockedBackend([CreateLeaveDefinition({ delayMs: 1_000, failureRate: 1 })]), ], }; export const Flaky: Story = { decorators: [ withMockedBackend([CreateLeaveDefinition({ delayMs: 1_000, failureRate: 0.5 })]), ], }; export const CapabilityError: Story = { decorators: [ withMockedBackend([ CreateLeaveDefinition({ failureRate: 1, error: { case: "capabilityError", value: { path: "create_leave_definition_key", }, }, }), ]), ], }; // TODO: Handle error at ManagerErrorCallout export const NotFoundError: Story = { decorators: [ withMockedBackend([ CreateLeaveDefinition({ failureRate: 1, error: { case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }, }), ]), ], }; export const MissingField: Story = { decorators: [ withMockedBackend([ CreateLeaveDefinition({ failureRate: 1, error: { case: "missingField", value: { path: "leave_definition.display_name", }, }, }), ]), ], };
-
-
-
@@ -1,270 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Container, Checkbox, Flex, Text, TextField } from "@radix-ui/themes"; import { CreateLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_request_pb.js"; import { CreateLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/create_leave_definition_response_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, use, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import * as Empty from "../../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import * as FormField from "../../../../components/FormField.ts"; import { useMethodMutation } from "../../../../contexts/Service.tsx"; import { NavigationContext } from "../../../../contexts/Router.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { Layout } from "../../Layout.tsx"; export const Title: FC = () => "休暇・休業種別登録"; interface BodyProps { workspace: Workspace; } const Body: FC<BodyProps> = ({ workspace }) => { const navigation = use(NavigationContext); const toast = useToast(); const startAtDefaultValue = useMemo<string>(() => { const now = new Date(); const yyyy = now.getFullYear().toString(10).padStart(4, "0"); const mm = (now.getMonth() + 1).toString(10).padStart(2, "0"); const dd = now.getDate().toString(10).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; }, []); const form = useForm<{ displayName: string; abbrName: string; isWorkerDeemedToBeWorked: boolean; startAt: string; }>({ defaultValues: { displayName: "", abbrName: "", isWorkerDeemedToBeWorked: true, startAt: startAtDefaultValue, }, mode: "onBlur", }); const creation = useMethodMutation({ service: "yamori.workspace.v1.WorkspaceService", method: "CreateLeaveDefinition", request: { schema: CreateLeaveDefinitionRequestSchema, }, response: { schema: CreateLeaveDefinitionResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, options: { onSuccess(leave) { toast.open({ icon: <CheckCircledIcon />, severity: "success", title: "休暇・休業種別を登録しました", description: `「${leave.displayName}」をワークスペースに登録しました。`, type: "foreground", }); navigation.push(`/${workspace.id?.value}/leave-definitions`); }, }, }); if (!workspace.createLeaveDefinitionKey) { return ( <Empty.Root> <Empty.Title>権限がありません</Empty.Title> <Empty.Description> このワークスペース上に休暇・休業を登録する権限がありません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={`/${workspace.id?.value}/leave-definitions`}>一覧へ</a> </Button> </Empty.Actions> </Empty.Root> ); } return ( <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit((values) => { const startAt = new Date(values.startAt); creation.mutate({ workspaceId: workspace.id, leaveDefinition: { displayName: values.displayName, abbreviationName: values.abbrName, revisions: [ { startAt: { year: startAt.getFullYear(), month: startAt.getMonth() + 1, day: startAt.getDate(), }, snapshot: { isWorkerDeemedToBeWorked: values.isWorkerDeemedToBeWorked, }, }, ], }, createLeaveDefinitionKey: workspace.createLeaveDefinitionKey, }); })} > {creation.error ? ( <ManagedErrorCallout error={creation.error} title="登録に失敗しました" actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } /> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="display_name_id">休暇・休業名</FormField.Label> <TextField.Root id="display_name_id" disabled={creation.isPending} placeholder="リフレッシュ休暇" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { required: "休暇・休業名は必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> 登録する休暇・休業の名前です。前後の空白は自動的に取り除かれます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="abbr_name_id">省略表記</FormField.Label> <TextField.Root id="abbr_name_id" disabled={creation.isPending} placeholder="リ休" autoComplete="off" {...form.register("abbrName", { setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description> カレンダーなどで表示する際の省略名です。前後の空白は自動的に取り除かれます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label>有給休暇計算時の扱い</FormField.Label> <Flex asChild gap="2"> <Text as="label" size="2"> <Controller control={form.control} name="isWorkerDeemedToBeWorked" render={({ field }) => { return ( <Checkbox ref={field.ref} disabled={creation.isPending} checked={field.value} name={field.name} onCheckedChange={field.onChange} onBlur={field.onBlur} /> ); }} /> 出勤したとみなす </Text> </Flex> <FormField.Description> 有効にすると、有給休暇を付与する際の出勤率計算時にこの休暇・休業を行った日を 出勤したものとみなします。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="start_at_id">運用開始日</FormField.Label> <TextField.Root id="start_at_id" type="date" disabled={creation.isPending} color={form.formState.errors.startAt ? "red" : undefined} aria-invalid={!!form.formState.errors.startAt} {...form.register("startAt", { required: "運用開始日は必須です", })} style={{ alignSelf: "flex-start" }} /> <FormField.Description error={form.formState.errors.startAt?.message}> この休暇・休業をいつの勤怠記録上から選択できるようにするか指定します。 </FormField.Description> </FormField.Root> <Button mt="5" loading={creation.isPending}> 登録 </Button> </form> </Flex> ); }; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { return ( <Layout workspace={workspace} title={<Title />}> <Container p="2" size="2"> <Body workspace={workspace} /> </Container> </Layout> ); }; export function createHref(workspace: Workspace): string { if (!workspace.id) { return "/"; } return `/${workspace.id.value}/leave-definitions/new`; } export const pattern = new URLPattern({ pathname: "/:workspace/leave-definitions/new", });
-
-
-
@@ -1,155 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Get, DeleteLeaveDefinition, } from "../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, }); const success = create(WorkspaceSchema, { leaveDefinitions: [ { id: { value: "lv-foo" }, displayName: "なにかの休業", isWorkerDeemedToBeWorked: true, updateKey: { key: new Uint8Array([]) }, deletionKey: { key: new Uint8Array([]) }, currentRevision: { revisionId: { value: "lr-bar" }, startAt: { year: 2000, month: 1, day: 2 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, revisions: [ { revisionId: { value: "lr-foo" }, startAt: { year: 1999, month: 4, day: 1 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, { revisionId: { value: "lr-bar" }, startAt: { year: 2000, month: 1, day: 2 }, snapshot: { isWorkerDeemedToBeWorked: true }, }, { revisionId: { value: "lr-baz" }, startAt: { year: 3000, month: 9, day: 9 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, ], }, { id: { value: "lv-bar" }, displayName: "なにかの休暇", isWorkerDeemedToBeWorked: false, currentRevision: { revisionId: { value: "lr-qux" }, startAt: { year: 2020, month: 2, day: 29 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, revisions: [ { revisionId: { value: "lr-qux" }, startAt: { year: 2020, month: 2, day: 29 }, snapshot: { isWorkerDeemedToBeWorked: false }, }, ], }, ], }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/leave-definitions" }), withMockedBackend([ Get({ workspace: success, }), DeleteLeaveDefinition(), ]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = {}; export const SlowLoad: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, delayMs: 3_000, }), DeleteLeaveDefinition({ delayMs: 1_000, }), ]), ], }; export const NoDefinitions: Story = { decorators: [ withMockedBackend([ Get({ workspace: {}, }), DeleteLeaveDefinition(), ]), ], }; export const LoadError: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, failureRate: 1, }), ]), ], }; export const Flaky: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, failureRate: 0.5, }), DeleteLeaveDefinition({ failureRate: 0.5, }), ]), ], }; export const DeleteError: Story = { decorators: [ withMockedBackend([ Get(), DeleteLeaveDefinition({ delayMs: 1_000, failureRate: 1, }), ]), ], };
-
-
-
@@ -1,252 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { Button, Container, Flex, Separator, Spinner, Text } from "@radix-ui/themes"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { DeleteLeaveDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_request_pb.js"; import { DeleteLeaveDefinitionResponseSchema } from "@yamori/proto/yamori/workspace/v1/delete_leave_definition_response_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema, type Workspace, } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, Fragment, useRef } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import * as HelpDialog from "../../../components/HelpDialog.ts"; import { useMethodQuery, useMethodMutation } from "../../../contexts/Service.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { Layout } from "../Layout.tsx"; import { ListItem } from "./ListItem.tsx"; interface BodyProps { workspace: Workspace; } const Body: FC<BodyProps> = ({ workspace: { id } }) => { const toast = useToast(); const definitions = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: id, readMask: { fields: [WorkspaceSchema.field.leaveDefinitions.number], leaveDefinitionsMask: { fields: [ LeaveSchema.field.id.number, LeaveSchema.field.displayName.number, LeaveSchema.field.abbreviationName.number, LeaveSchema.field.currentRevision.number, LeaveSchema.field.revisions.number, LeaveSchema.field.deletionKey.number, ], }, }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.leaveDefinitions; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); const deletingToastCleanup = useRef<(() => void) | null>(null); const deleteLeaveDefinition = useMethodMutation({ service: "yamori.workspace.v1.WorkspaceService", method: "DeleteLeaveDefinition", request: { schema: DeleteLeaveDefinitionRequestSchema, }, response: { schema: DeleteLeaveDefinitionResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": if (!resp.result.value.deleted) { throw new IllegalMessageError(resp); } return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, options: { onMutate() { deletingToastCleanup.current = toast.open({ severity: "info", title: "休暇・休業種別を削除しています...", onOpenChange(isOpen) { if (!isOpen) { deletingToastCleanup.current = null; } }, }); }, onSettled() { deletingToastCleanup.current?.(); deletingToastCleanup.current = null; }, onSuccess({ deleted }) { toast.open({ severity: "success", icon: <CheckCircledIcon />, title: "休暇・休業種別を削除しました", description: `「${deleted?.displayName}」をワークスペースから削除しました。`, }); definitions.refetch(); }, onError(error) { // TODO: Handle error console.error(error); toast.open({ severity: "danger", icon: <CrossCircledIcon />, title: "休暇・休業種別の削除に失敗しました", duration: 10_000, dismissible: true, }); }, }, }); if (definitions.data?.length === 0 && !definitions.isError) { return ( <Empty.Root> <Empty.Title>データがありません</Empty.Title> <Empty.Description> このワークスペースには休暇・休業が一つも登録されていません。 </Empty.Description> </Empty.Root> ); } return ( <Flex direction="column" mt="5" gap="5"> {!!definitions.error && ( <ManagedErrorCallout severity={definitions.data ? "warning" : "danger"} title={ definitions.data ? "一覧の更新に失敗しました" : "一覧の取得に失敗しました" } error={definitions.error} actions={ <Button size="1" loading={definitions.isFetching} onClick={() => void definitions.refetch()} > {definitions.data ? "再試行" : "再取得"} </Button> } /> )} {!definitions.data ? ( <Flex mt="5" justify="center" align="center" gap="2"> {definitions.isError ? ( <Text size="2" color="gray"> 取得エラー </Text> ) : ( <> <Spinner /> <Text size="2" color="gray"> 一覧を取得中... </Text> </> )} </Flex> ) : ( <Flex direction="column" gap="4" role="list"> {definitions.data.map((def, i) => ( <Fragment key={def.id?.value}> {i > 0 && <Separator size="4" />} <ListItem leaveDefinition={def} pending={deleteLeaveDefinition.isPaused} pageTitle={<Title />} onDelete={() => void deleteLeaveDefinition.mutate({ workspaceId: id, leaveDefinitionId: def.id, deletionKey: def.deletionKey, readMask: { fields: [LeaveSchema.field.displayName.number] }, }) } /> </Fragment> ))} </Flex> )} </Flex> ); }; export const Title: FC = () => "休暇・休業種別一覧"; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { return ( <HelpDialog.Root> <Layout workspace={workspace} title={ <Flex as="span" align="center" gap="2"> <Title /> <HelpDialog.Trigger>このページの説明</HelpDialog.Trigger> </Flex> } > <Container p="2" size="2"> <Body workspace={workspace} /> </Container> </Layout> <HelpDialog.Content> <HelpDialog.Title> <Title /> </HelpDialog.Title> <HelpDialog.Description> このワークスペース内で利用可能な休暇と休業の一覧です。 </HelpDialog.Description> <HelpDialog.Paragraph> 労働者の勤怠記録をつける際は、この一覧にある項目から選ぶことができます。 労働者やグループの設定ページで「休暇休業の絞り込み」が設定されている場合は、絞り込まれた項目の中からのみ選ぶことができます。 </HelpDialog.Paragraph> </HelpDialog.Content> </HelpDialog.Root> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/leave-definitions", });
-
-
-
@@ -1,71 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Decorator, Meta, StoryObj } from "@storybook/react"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { On } from "../../contexts/Router.tsx"; import * as workspaceService from "../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page, pattern } from "./page.tsx"; function withRoute<Args>(): Decorator<Args> { return (Story) => ( <On pattern={pattern}> <Story /> </On> ); } export default { component: Page, parameters: { layout: "fullscreen", }, decorators: [withMockedBackend([workspaceService.Get()])], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const NotFound: Story = { decorators: [withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/invalid" })], }; export const Loading: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/invalid" }), withMockedBackend([workspaceService.Get({ delayMs: 5_000 })]), ], }; export const NonexistentWorkspace: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-000/invalid" }), withMockedBackend([ workspaceService.Get({ failureRate: 1, error: { case: "notFound", value: {} } }), ]), ], }; export const LoadError: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/workers" }), withMockedBackend([ workspaceService.Get({ failureRate: 1, error: { case: "systemError", value: { code: "SAMPLE_ERR", message: "This is sample error", }, }, }), ]), ], };
-
-
-
@@ -1,176 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../polyfill.ts"; import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, Container, Flex, Spinner, Text } from "@radix-ui/themes"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC } from "react"; import * as Empty from "../../components/Empty.ts"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import { Select, useURLPatternResult } from "../../contexts/Router.tsx"; import { useMethodQuery } from "../../contexts/Service.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import * as calendar from "./calendar/page.tsx"; import * as paidLeaveProvisionTable from "./paid-leave-provision-table/page.tsx"; import * as summary from "./summary/page.tsx"; import * as workers from "./workers/page.tsx"; import * as workersNew from "./workers/new/page.tsx"; import * as workerSubRoute from "./workers/:id/page.tsx"; import * as leaveDefinitions from "./leave-definitions/page.tsx"; import * as leaveDefinitionsNew from "./leave-definitions/new/page.tsx"; import { Layout } from "./Layout.tsx"; export const Page: FC = () => { const { pathname } = useURLPatternResult(); const workspaceIdRaw = pathname.groups.workspace!; const query = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: { value: workspaceIdRaw }, readMask: { fields: [ WorkspaceSchema.field.id.number, WorkspaceSchema.field.displayName.number, WorkspaceSchema.field.workerAddKey.number, WorkspaceSchema.field.createLeaveDefinitionKey.number, ], }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); if (query.isError) { if (isMessage(query.error, NotFoundSchema)) { return ( <Container p="3" size="2"> <Empty.Root> <Empty.Title>ワークスペースが見つかりません</Empty.Title> <Empty.Description> ID が <Code>{workspaceIdRaw}</Code> のワークスペースが見つかりませんでした。 対象のワークスペースが既に削除されたか、打ち間違いや削除などにより ID が不完全の可能性があります。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href="/">選択画面へ</a> </Button> </Empty.Actions> </Empty.Root> </Container> ); } return ( <Container p="3" size="2"> <Empty.Root> <Empty.Title>ワークスペースの取得に失敗</Empty.Title> <Empty.Description> ワークスペースを読み込もうとしましたが、データ取得に失敗しました。 </Empty.Description> <ManagedErrorCallout title="取得失敗" error={query.error} /> <Empty.Actions> <Button size="3" onClick={() => void query.refetch()}> 再取得 </Button> <Button asChild size="3" variant="outline"> <a href="/">選択画面へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> ); } if (!query.data) { return ( <Flex position="absolute" inset="0" align="center" justify="center" gap="2"> <Spinner /> <Text size="2" color="gray"> ワークスペースを読込中... </Text> </Flex> ); } const workspace = query.data; return ( <Select routes={[ { pattern: workers.pattern, children: <workers.Page workspace={workspace} />, }, { pattern: workersNew.pattern, children: <workersNew.Page workspace={workspace} />, }, { pattern: leaveDefinitions.pattern, children: <leaveDefinitions.Page workspace={workspace} />, }, { pattern: leaveDefinitionsNew.pattern, children: <leaveDefinitionsNew.Page workspace={workspace} />, }, { pattern: calendar.pattern, children: <calendar.Page workspace={workspace} />, }, { pattern: summary.pattern, children: <summary.Page workspace={workspace} />, }, { pattern: paidLeaveProvisionTable.pattern, children: <paidLeaveProvisionTable.Page workspace={workspace} />, }, { pattern: workerSubRoute.pattern, children: <workerSubRoute.Page workspace={workspace} />, }, ]} fallback={ <Layout title="404" workspace={workspace}> <Empty.Root> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={calendar.href({ workspace })}>労働者一覧へ</a> </Button> </Empty.Actions> </Empty.Root> </Layout> } /> ); }; export const pattern = new URLPattern({ pathname: "/:workspace(ws-[^\\/]+)/:frag*", });
-
-
-
@@ -1,91 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Get } from "../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page } from "./page.tsx"; const success = proto.create(WorkspaceSchema, { paidLeaveProvisionTables: [ { id: { value: "pt-a" }, displayName: "テーブルA", currentRevision: { firstProvisionAmountDays: 1, subsequentProvisionAmountDays: [2, 3, 4, 5], }, }, { id: { value: "pt-b" }, displayName: "テーブルB", currentRevision: { firstProvisionAmountDays: 10, subsequentProvisionAmountDays: [20, 30, 40, 50], }, }, ], }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/paid-leave-provision-table" }), withMockedBackend([ Get({ workspace: success, }), ]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = {}; export const SlowLoad: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, delayMs: 3_000, }), ]), ], }; export const LoadError: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, failureRate: 1, }), ]), ], }; export const Flaky: Story = { decorators: [ withMockedBackend([ Get({ workspace: success, failureRate: 0.5, delayMs: 1_000, }), ]), ], };
-
-
-
@@ -1,146 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import * as proto from "@bufbuild/protobuf"; import { Box, Button, Container, Flex, Heading, Section, Spinner, Text, } from "@radix-ui/themes"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { PaidLeaveProvisionTableSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_pb.js"; import { type FC } from "react"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { PaidLeaveProvisionTableView } from "../../../components/PaidLeaveProvisionTableView.tsx"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { Layout } from "../Layout.tsx"; export const Title: FC = () => "有給休暇付与テーブル"; export interface HrefInput { workspace: proto.MessageShape<typeof WorkspaceSchema>; } export function href({ workspace }: HrefInput): string { if (!workspace.id) { return "/"; } return `/${workspace.id.value}/paid-leave-provision-table`; } export const pattern = new URLPattern({ pathname: "/:workspace/paid-leave-provision-table", }); export interface PageProps { workspace: proto.MessageShape<typeof WorkspaceSchema>; } export const Page: FC<PageProps> = ({ workspace }) => { return ( <Layout workspace={workspace} title={<Title />}> <Body workspaceID={workspace.id} /> </Layout> ); }; interface BodyProps { workspaceID: proto.MessageShape<typeof WorkspaceSchema>["id"]; } const Body: FC<BodyProps> = ({ workspaceID }) => { const query = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspaceID, readMask: { fields: [WorkspaceSchema.field.paidLeaveProvisionTables.number], paidLeaveProvisionTableMask: { fields: [ PaidLeaveProvisionTableSchema.field.id.number, PaidLeaveProvisionTableSchema.field.displayName.number, PaidLeaveProvisionTableSchema.field.currentRevision.number, ], }, }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.paidLeaveProvisionTables.sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), ); case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); return ( <Container asChild p="2" size="2"> <Flex direction="column" gap="2"> {query.isError && ( <ManagedErrorCallout error={query.error} title="一覧の取得に失敗しました" actions={ <Button size="1" loading={query.isFetching} onClick={() => void query.refetch()} > 再取得 </Button> } /> )} {query.isLoading && ( <Flex mt="5" justify="center" align="center" gap="2"> <Spinner /> <Text size="2" color="gray"> テーブルの一覧を取得中... </Text> </Flex> )} {query.data && ( <Box> {query.data.map((table) => { return ( <Section key={table.id?.value}> <Heading as="h2" mb="2"> {table.displayName} </Heading> <PaidLeaveProvisionTableView paidLeaveProvisionTable={table} /> </Section> ); })} </Box> )} </Flex> </Container> ); };
-
-
-
@@ -1,46 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only export type TabluarData = (string | number)[][]; /** * <https://www.iana.org/assignments/media-types/text/tab-separated-values> */ export function createTSV(data: TabluarData): string { return data .map((row) => row .map((cell) => { if (typeof cell === "number") { return cell.toString(10); } return cell.replace(/[\r\n\t]/g, ""); }) .join("\t"), ) .join("\r\n"); } /** * <https://datatracker.ietf.org/doc/html/rfc4180> */ export function createCSV(data: TabluarData): string { return data .map((row) => row .map((cell) => { if (typeof cell === "number") { return cell.toString(10); } if (!/[,"\n]/.test(cell)) { return cell; } return `"${cell.replace(/"/g, `""`)}"`; }) .join(","), ) .join("\r\n"); }
-
-
-
@@ -1,119 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { describe, test, expect } from "bun:test"; import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { href, deserializeDate, serializeDate } from "./meta.ts"; describe("serializeDate", () => { test("Should serialize full date", () => { expect(serializeDate({ year: 2020, month: 11, day: 30 })).toBe("2020-11-30"); }); test("Should pad month and day", () => { expect(serializeDate({ year: 2020, month: 1, day: 1 })).toBe("2020-01-01"); }); }); describe("deserializeDate", () => { test("Should deserialize full date", () => { expect(deserializeDate("2020-02-12")).toMatchObject({ year: 2020, month: 2, day: 12, }); }); test("Should deserialize non-0-prefixed numbers", () => { expect(deserializeDate("2020-2-2")).toMatchObject({ year: 2020, month: 2, day: 2, }); }); test("Should not parse non-base10 numbers", () => { expect(deserializeDate("2020-02-ff")).toBeNull(); }); }); describe("href", () => { test("Should not append search part when both since and until are empty", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), }), ).toBe("/ws-foo/summary"); }); test("Should set since param", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), since: proto.create(DateSchema, { year: 2020, month: 2, day: 2, }), }), ).toBe("/ws-foo/summary?since=2020-02-02"); }); test("Should set since param", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), since: proto.create(DateSchema, { year: 2020, month: 2, day: 2, }), }), ).toBe("/ws-foo/summary?since=2020-02-02"); }); test("Should set until param", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), until: proto.create(DateSchema, { year: 2020, month: 2, day: 2, }), }), ).toBe("/ws-foo/summary?until=2020-02-02"); }); test("Should set search params", () => { expect( href({ workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }), since: proto.create(DateSchema, { year: 2020, month: 2, day: 1, }), until: proto.create(DateSchema, { year: 2020, month: 3, day: 1, }), }), ).toBe("/ws-foo/summary?since=2020-02-01&until=2020-03-01"); }); });
-
-
-
@@ -1,69 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import * as proto from "@bufbuild/protobuf"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { type WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; export function serializeDate( date: Omit<proto.MessageShape<typeof DateSchema>, `$${string}`>, ): string { return `${date.year}-${date.month.toString().padStart(2, "0")}-${date.day.toString().padStart(2, "0")}`; } export function deserializeDate( str: string, ): proto.MessageShape<typeof DateSchema> | null { const [yyyy, mm, dd] = str.split("-"); if (!yyyy || !mm || !dd) { return null; } const year = parseInt(yyyy.replace(/^0*/, ""), 10); const month = parseInt(mm.replace(/^0*/, ""), 10); const day = parseInt(dd.replace(/^0*/, ""), 10); if (!isFinite(year) || !isFinite(month) || !isFinite(day)) { return null; } return proto.create(DateSchema, { year, month, day }); } export interface HrefInput { workspace: proto.MessageShape<typeof WorkspaceSchema>; since?: proto.MessageShape<typeof DateSchema>; until?: proto.MessageShape<typeof DateSchema>; } export function buildSearchParams({ since, until }: Pick<HrefInput, "since" | "until">) { const searchParams = new URLSearchParams(); if (since) { searchParams.set("since", serializeDate(since)); } if (until) { searchParams.set("until", serializeDate(until)); } return searchParams; } export function href({ workspace, since, until }: HrefInput): string { if (!workspace.id) { return "/"; } const base = `/${workspace.id.value}/summary`; const searchParams = buildSearchParams({ since, until }); return searchParams.size > 0 ? base + "?" + searchParams : base; } export const pattern = new URLPattern({ pathname: "/:workspace/summary", });
-
-
-
@@ -1,33 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .cell { white-space: nowrap; } .sticky { position: sticky; background-color: var(--color-background); z-index: 10; } .columnHeader { composes: cell sticky; top: 0; } .rowHeader { composes: cell sticky; left: 0; } .rowColumnHeader { composes: columnHeader rowHeader; z-index: 11; }
-
-
-
@@ -1,49 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Get } from "../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { List } from "../../../mocks/yamori/worker/v1/worker_service.ts"; import { Page } from "./page.tsx"; const workspace = proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/summary" }), withMockedBackend([Get(), List()]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = {}; export const RangeInURL: Story = { decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/summary?since=2020-01-01&until=2020-01-31", }), ], }; export const InvalidDate: Story = { decorators: [ withInmemoryRouter({ initialURL: "/ws-foo/summary?since=2020-01&until=2020-02" }), ], };
-
-
-
@@ -1,522 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { tz } from "@date-fns/tz"; import { CaretLeftIcon, CaretRightIcon } from "@radix-ui/react-icons"; import { Button, Box, Container, DropdownMenu, Flex, Link, Table, Text, type TextProps, Tooltip, } from "@radix-ui/themes"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { RecordKindSchema } from "@yamori/proto/yamori/work_record/v1/record_kind_pb.js"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { addMonths, differenceInCalendarDays, startOfMonth, endOfMonth } from "date-fns"; import { type FC, type ReactNode, use, useEffect, useMemo } from "react"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { NavigationContext, URLContext } from "../../../contexts/Router.tsx"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { fromProtoDate, toProtoDate } from "../../../helpers.ts"; import * as workerDashboard from "../workers/:id/Dashboard.tsx"; import { Layout } from "../Layout.tsx"; import { createCSV, createTSV } from "./exports.ts"; import { buildSearchParams, deserializeDate, serializeDate, href } from "./meta.ts"; import css from "./page.module.css"; export { href, pattern } from "./meta.ts"; export const Title: FC = () => "集計表"; interface DateRange { since: proto.MessageShape<typeof DateSchema>; until: proto.MessageShape<typeof DateSchema>; } interface DayCountProps extends Pick<TextProps, "color"> { suffix?: ReactNode; count: number; } const DayCount: FC<DayCountProps> = ({ suffix = "日", color, count }) => { if (!count) { return ( <Text size="2" color="gray" style={{ opacity: 0.2 }}> --- </Text> ); } return ( <Text size="3"> <Text color={color}>{count}</Text> <Text ml="1" size="1"> {suffix} </Text> </Text> ); }; const monthFormatter = new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "numeric", }); interface BodyProps { workspace: proto.MessageShape<typeof WorkspaceSchema>; range: DateRange; onChangeDateRange(range: DateRange): void; } const Body: FC<BodyProps> = ({ workspace, range: { since, until }, onChangeDateRange, }) => { const workspaceConfig = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, readMask: { fields: [ WorkspaceSchema.field.abbreviations.number, WorkspaceSchema.field.leaveDefinitions.number, ], leaveDefinitionsMask: { fields: [ LeaveSchema.field.id.number, LeaveSchema.field.displayName.number, LeaveSchema.field.abbreviationName.number, ], }, }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); const workers = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspace.id, workRecordFilter: { since, until, }, readMask: { fields: [ WorkerSchema.field.id.number, WorkerSchema.field.displayName.number, WorkerSchema.field.workRecords.number, ], workRecordsMask: { fields: [ WorkRecordSchema.field.date.number, WorkRecordSchema.field.dayWhole.number, WorkRecordSchema.field.dayHalved.number, ], }, }, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.workers; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); const prevMonth = useMemo(() => { // TODO: TZ をどこか共通の場所で定義する return addMonths(fromProtoDate(since), -1, { in: tz("Asia/Tokyo") }); }, [since]); const nextMonth = useMemo(() => { // TODO: TZ をどこか共通の場所で定義する return addMonths(fromProtoDate(since), 1, { in: tz("Asia/Tokyo") }); }, [since]); const datesCount = useMemo<number>(() => { return differenceInCalendarDays(fromProtoDate(until), fromProtoDate(since)) + 1; }, [since, until]); const header = useMemo<{ name: string; abbr?: string }[]>(() => { return [ { name: "労働者名", }, { name: workspaceConfig.data?.abbreviations?.worked || "出勤", }, { name: workspaceConfig.data?.abbreviations?.dayoff || "休日", }, { name: workspaceConfig.data?.abbreviations?.skipWork || "欠勤", }, { name: "年次有給休暇", abbr: workspaceConfig.data?.abbreviations?.paidLeave, }, ...(workspaceConfig.data?.leaveDefinitions.map((def) => { return { name: def.displayName, abbr: def.abbreviationName, }; }) ?? []), { name: "未入力", }, ]; }, [workspaceConfig.data]); const body = useMemo<[proto.MessageShape<typeof WorkerSchema>, ...number[]][]>(() => { return ( workers.data?.map((worker) => { let worked = 0; let dayoff = 0; let skipped = 0; let paidLeave = 0; const workspaceDefinedLeaves = new Map<string, number>(); const incr = ( record: proto.MessageShape<typeof RecordKindSchema>, amount: number = 1, ): void => { switch (record.kind.case) { case "worked": worked += amount; return; case "skipped": skipped += amount; return; case "paidLeave": paidLeave += amount; return; case "dayOff": dayoff += amount; return; case "workspaceDefinedLeave": if (record.kind.value.id?.value) { const current = workspaceDefinedLeaves.get(record.kind.value.id.value); workspaceDefinedLeaves.set( record.kind.value.id.value, (current ?? 0) + amount, ); } return; } }; for (const record of worker.workRecords) { switch (record.kind.case) { case "dayWhole": { incr(record.kind.value); break; } case "dayHalved": { if (record.kind.value.am) { incr(record.kind.value.am, 0.5); } if (record.kind.value.pm) { incr(record.kind.value.pm, 0.5); } break; } } } const emptyDates = datesCount - worker.workRecords.filter((r) => !!r.kind.case).length; return [ worker, worked, dayoff, skipped, paidLeave, ...(workspaceConfig.data?.leaveDefinitions.map((def) => { return (def.id && workspaceDefinedLeaves.get(def.id.value)) || 0; }) ?? []), emptyDates, ]; }) ?? [] ); }, [workers.data, workspaceConfig.data]); return ( <Flex position="absolute" inset="0" direction="column"> {workspaceConfig.isError && ( <ManagedErrorCallout error={workspaceConfig.error} title="ワークスペース設定が取得できませんでした" actions={ <Button size="1" loading={workspaceConfig.isFetching} onClick={() => void workspaceConfig.refetch()} > 再試行 </Button> } /> )} {workers.isError && ( <ManagedErrorCallout error={workers.error} title="労働者一覧を取得できませんでした" actions={ <Button size="1" loading={workspaceConfig.isFetching} onClick={() => void workers.refetch()} > 再試行 </Button> } /> )} <Container p="3" flexShrink="0" flexGrow="0"> <Flex align="center" justify="end" gap="1"> <Button variant="soft" onClick={() => { onChangeDateRange({ // TODO: TZ をどこか共通の場所で定義する since: toProtoDate(startOfMonth(prevMonth, { in: tz("Asia/Tokyo") })), until: toProtoDate(endOfMonth(prevMonth, { in: tz("Asia/Tokyo") })), }); }} > <CaretLeftIcon /> {monthFormatter.format(prevMonth)} </Button> <Button variant="soft" onClick={() => { onChangeDateRange({ // TODO: TZ をどこか共通の場所で定義する since: toProtoDate(startOfMonth(nextMonth, { in: tz("Asia/Tokyo") })), until: toProtoDate(endOfMonth(nextMonth, { in: tz("Asia/Tokyo") })), }); }} > {monthFormatter.format(nextMonth)} <CaretRightIcon /> </Button> <DropdownMenu.Root> <DropdownMenu.Trigger> <Button> ダウンロード <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> <DropdownMenu.Content align="end"> <DropdownMenu.Item disabled={!workers.isFetched || !workspaceConfig.isFetched} onClick={() => { const tsv = createTSV([ header.map(({ name }) => name), ...body.map(([worker, ...cells]) => [worker.displayName, ...cells]), ]); const file = new Blob([tsv], { type: "text/tab-separated-values;charset=UTF-8", }); const anchor = document.createElement("a"); anchor.download = `${serializeDate(since)}_${serializeDate(until)}.tsv`; anchor.href = URL.createObjectURL(file); anchor.click(); }} > TSVファイル </DropdownMenu.Item> <DropdownMenu.Item disabled={!workers.isFetched || !workspaceConfig.isFetched} onClick={() => { const csv = createCSV([ header.map(({ name }) => name), ...body.map(([worker, ...cells]) => [worker.displayName, ...cells]), ]); const file = new Blob([csv], { type: "text/csv;charset=UTF-8" }); const anchor = document.createElement("a"); anchor.download = `${serializeDate(since)}_${serializeDate(until)}.csv`; anchor.href = URL.createObjectURL(file); anchor.click(); }} > CSVファイル </DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Root> </Flex> </Container> <Box mt="2" p="2" flexGrow="1" flexShrink="1" minWidth="0" minHeight="0"> <Table.Root style={{ maxHeight: "100%" }}> <Table.Header> <Table.Row> {header.map(({ name, abbr }, i) => { return ( <Table.ColumnHeaderCell key={i} className={i === 0 ? css.rowColumnHeader : css.columnHeader} justify={i === 0 ? "start" : "center"} > {abbr ? ( <Tooltip content={name}> <Text>{abbr}</Text> </Tooltip> ) : ( name )} </Table.ColumnHeaderCell> ); })} </Table.Row> </Table.Header> <Table.Body> {body.map((row, i) => { return ( <Table.Row key={i}> {row.map((cell, j, cells) => { if (typeof cell !== "number") { return ( <Table.RowHeaderCell key={j} className={css.rowHeader}> <Link href={workerDashboard.href({ workspace, worker: cell })}> {cell.displayName} </Link> </Table.RowHeaderCell> ); } return ( <Table.Cell key={j} className={css.cell} justify="center"> <DayCount color={j === cells.length - 1 ? "red" : undefined} count={cell} /> </Table.Cell> ); })} </Table.Row> ); })} </Table.Body> </Table.Root> </Box> </Flex> ); }; export interface PageProps { workspace: proto.MessageShape<typeof WorkspaceSchema>; } export const Page: FC<PageProps> = ({ workspace }) => { const navigation = use(NavigationContext); const url = use(URLContext); const searchParams = useMemo(() => new URLSearchParams(url.search), [url.search]); const range = useMemo<DateRange>(() => { const parseOr = ( paramName: string, fallback: Date, ): proto.MessageShape<typeof DateSchema> => { const param = searchParams.get(paramName); if (!param) { return toProtoDate(fallback); } return deserializeDate(param) || toProtoDate(fallback); }; // TODO: TZ をどこか共通の場所で定義する const since = parseOr("since", startOfMonth(Date.now(), { in: tz("Asia/Tokyo") })); const until = parseOr("until", endOfMonth(Date.now(), { in: tz("Asia/Tokyo") })); return { since, until }; }, [searchParams]); // ブラウザのクエリパラメータと since/until が違う場合はブラウザのクエリパラメータを // 書き換える。 useEffect(() => { const next = buildSearchParams({ since: range.since, until: range.until }); if (next.toString() === searchParams.toString()) { return; } navigation.replace(href({ workspace, since: range.since, until: range.until })); }, [searchParams, range]); return ( <Layout workspace={workspace} title={<Title />}> <Body workspace={workspace} range={range} onChangeDateRange={({ since, until }) => { navigation.replace( href({ workspace, since, until, }), ); }} /> </Layout> ); };
-
-
-
@@ -1,209 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import * as proto from "@bufbuild/protobuf"; import { Box, Container, Flex, Heading, Separator } from "@radix-ui/themes"; import { LeaveSchema } from "@yamori/proto/yamori/work_record/v1/leave_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { PaidLeaveProvisionSchema } from "@yamori/proto/yamori/worker/v1/paid_leave_provision_pb.js"; import { type Worker, WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { WorkerReadMaskSchema } from "@yamori/proto/yamori/worker/v1/worker_read_mask_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { subDays } from "date-fns"; import { type FC, Fragment, useMemo } from "react"; import { useMethodQuery } from "../../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { isSameDate, toProtoDate } from "../../../../helpers.ts"; import { Layout } from "../../Layout.tsx"; import { DatesViewer } from "./Dashboard/DatesViewer.tsx"; import { DatesViewerCell } from "./Dashboard/DatesViewerCell.tsx"; import { PaidLeaves } from "./Dashboard/PaidLeaves.tsx"; import { useDateCells } from "./Dashboard/useDateCells.ts"; export const Title: FC = () => "ダッシュボード"; const readMask = proto.create(WorkerReadMaskSchema, { fields: [ WorkerSchema.field.writeWorkRecordKey.number, WorkerSchema.field.providePaidLeaveKey.number, WorkerSchema.field.workRecords.number, WorkerSchema.field.paidLeaveProvisions.number, ], paidLeaveProvisionsMask: { fields: [ PaidLeaveProvisionSchema.field.providedAt.number, PaidLeaveProvisionSchema.field.expiresAt.number, PaidLeaveProvisionSchema.field.note.number, PaidLeaveProvisionSchema.field.amountDays.number, PaidLeaveProvisionSchema.field.remainingDays.number, PaidLeaveProvisionSchema.field.isHalvedDayRemaining.number, ], }, workRecordsMask: { fields: [ WorkRecordSchema.field.date.number, WorkRecordSchema.field.note.number, WorkRecordSchema.field.dayWhole.number, WorkRecordSchema.field.dayHalved.number, ], workspaceDefinedLeaveMask: { fields: [ LeaveSchema.field.id.number, LeaveSchema.field.displayName.number, LeaveSchema.field.currentRevision.number, ], }, }, }); const loadingWorkerData = proto.create(WorkerSchema, { paidLeaveProvisions: [ { providedAt: { year: 1088, month: 1, day: 1 }, expiresAt: { year: 1088, month: 1, day: 1 }, amountDays: 10, remainingDays: 8, isHalvedDayRemaining: true, }, { providedAt: { year: 1089, month: 1, day: 2 }, expiresAt: { year: 1089, month: 1, day: 2 }, amountDays: 10, remainingDays: 10, }, ], }); interface BodyProps { workspace: Workspace; worker: Worker; daysToDisplay?: number; } const Body: FC<BodyProps> = ({ workspace, worker, daysToDisplay = 7 }) => { const now = new Date(); const [today, since] = useMemo(() => { return [toProtoDate(now), toProtoDate(subDays(now, daysToDisplay))]; }, [now.getDay()]); const dates = useDateCells(since, today); const query = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, workerId: worker.id, readMask: readMask, workRecordFilter: { since: since, until: today, }, paidLeaveProvisionFilter: { providedAtUntil: today, expiresAtSince: today, }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); return ( <Flex direction="column"> <Heading as="h2" size="2" mt="4" mb="2"> 年次有給休暇 </Heading> <PaidLeaves worker={query.data || loadingWorkerData} loading={!query.data} /> <Heading as="h2" size="2" mt="6" mb="2"> 直近{daysToDisplay}日間の勤怠 </Heading> <DatesViewer> {dates.map((date, i) => ( <Fragment key={i}> {i > 0 && ( <Box asChild flexShrink="0" flexGrow="0"> <Separator orientation={{ initial: "horizontal", sm: "vertical" }} size="4" /> </Box> )} <DatesViewerCell date={date} workRecord={query.data?.workRecords.find( (r) => r.date && isSameDate(r.date, date), )} first={i === 0} flexBasis="100%" flexGrow="1" flexShrink="1" minWidth="0" py={{ initial: "3", sm: "0" }} px={{ initial: "1", sm: "2" }} height="100%" /> </Fragment> ))} </DatesViewer> </Flex> ); }; export interface PageProps { workspace: Workspace; worker: Worker; } export const Page: FC<PageProps> = ({ workspace, worker }) => { return ( <Layout workspace={workspace} worker={worker} title={<Title />}> <Container py="2" px="3" size="3"> <Body workspace={workspace} worker={worker} /> </Container> </Layout> ); }; export interface HrefInput { workspace: Workspace; worker: Worker; } export function href({ workspace, worker }: HrefInput): string { if (!workspace.id) { return "/"; } if (!worker.id) { return `/${workspace.id.value}/workers`; } return `/${workspace.id.value}/workers/${worker.id.value}`; } export const pattern = new URLPattern({ pathname: "/:workspace/workers/:worker", });
-
-
-
@@ -1,21 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; export interface DatesViewerProps { children?: ReactNode; } export const DatesViewer: FC<DatesViewerProps> = ({ children }) => { return ( <Flex height={{ initial: undefined, sm: "10em" }} align="stretch" direction={{ initial: "column", sm: "row" }} > {children} </Flex> ); };
-
-
-
@@ -1,41 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { DatesViewerCell } from "./DatesViewerCell.tsx"; export default { component: DatesViewerCell, args: { first: false, date: proto.create(DateSchema, { year: 2020, month: 1, day: 1, }), }, } satisfies Meta<typeof DatesViewerCell>; type Story = StoryObj<typeof DatesViewerCell>; export const Empty: Story = {}; export const DayOff: Story = { args: { workRecord: proto.create(WorkRecordSchema, { kind: { case: "dayWhole", value: { kind: { case: "dayOff", value: {}, }, }, }, }), }, };
-
-
-
@@ -1,47 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { Flex, type FlexProps, Text } from "@radix-ui/themes"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { WorkRecordSchema } from "@yamori/proto/yamori/work_record/v1/work_record_pb.js"; import { type FC } from "react"; import { WorkRecordBadges } from "../../../../../components/WorkRecordBadges.tsx"; export interface DatesViewerCellProps extends Omit<FlexProps, "as" | "asChild" | "direction"> { date: proto.MessageShape<typeof DateSchema>; workRecord?: proto.MessageShape<typeof WorkRecordSchema>; first?: boolean; } export const DatesViewerCell: FC<DatesViewerCellProps> = ({ date, workRecord, first = false, ...rest }) => { return ( <Flex {...rest} direction="column" gap="1"> <Text size="1" style={{ visibility: first || date.day === 1 ? "visible" : "hidden" }} > {date.year}/{date.month} </Text> <Text size="2" weight="bold"> {date.day} </Text> {workRecord?.kind.case ? ( <WorkRecordBadges workRecord={workRecord} /> ) : ( <Text truncate size="1" color="gray"> 未入力 </Text> )} </Flex> ); };
-
-
-
@@ -1,69 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { PaidLeaves } from "./PaidLeaves.tsx"; export default { component: PaidLeaves, args: { worker: proto.create(WorkerSchema, { paidLeaveProvisions: [ { providedAt: { year: 2024, month: 3, day: 1, }, expiresAt: { year: 2026, month: 3, day: 1, }, amountDays: 10, remainingDays: 3, isHalvedDayRemaining: true, }, { providedAt: { year: 2025, month: 3, day: 1, }, expiresAt: { year: 2027, month: 3, day: 1, }, amountDays: 10, remainingDays: 10, }, ], }), loading: false, }, argTypes: { worker: { control: false, }, }, } satisfies Meta<typeof PaidLeaves>; type Story = StoryObj<typeof PaidLeaves>; export const Default: Story = {}; export const Empty: Story = { args: { worker: proto.create(WorkerSchema), }, }; export const Loading: Story = { args: { loading: true, }, };
-
-
-
@@ -1,85 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { InfoCircledIcon } from "@radix-ui/react-icons"; import { Box, Card, Flex, Skeleton, Text } from "@radix-ui/themes"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type FC } from "react"; import { fromProtoDate } from "../../../../../helpers.ts"; const dateFormatter = new Intl.DateTimeFormat(navigator.language, { dateStyle: "medium", }); export interface PaidLeavesProps { worker: Worker; loading?: boolean; } export const PaidLeaves: FC<PaidLeavesProps> = ({ worker, loading }) => { if (!worker.paidLeaveProvisions.length) { return ( <Flex asChild align="center" gap="1" my="4"> <Text color="gray"> <InfoCircledIcon /> <Text size="2">利用可能な年次有給休暇はありません</Text> </Text> </Flex> ); } return ( <Flex gap="2"> {worker.paidLeaveProvisions.map((provision, i) => { return ( <Skeleton key={ provision.providedAt ? fromProtoDate(provision.providedAt).toISOString() : i } loading={loading} > <Card> <Box> {provision.remainingDays === 0 && !provision.isHalvedDayRemaining ? ( <Text as="div" mb="1" color="gray"> 全て取得済み </Text> ) : ( <Text as="div" mb="1"> 残り <Text weight="bold"> {provision.remainingDays > 0 ? `${provision.remainingDays}日${provision.isHalvedDayRemaining ? "半" : ""}` : provision.isHalvedDayRemaining ? "半日" : "0日"} </Text> 利用可能 </Text> )} <Text as="div" size="2" color="gray"> {provision.providedAt && provision.expiresAt ? ( dateFormatter.formatRange( fromProtoDate(provision.providedAt), fromProtoDate(provision.expiresAt), ) ) : ( <> {provision.providedAt && dateFormatter.format(fromProtoDate(provision.providedAt))} {" ~ "} {provision.expiresAt && dateFormatter.format(fromProtoDate(provision.expiresAt))} </> )} </Text> </Box> </Card> </Skeleton> ); })} </Flex> ); };
-
-
-
@@ -1,24 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import { tz } from "@date-fns/tz"; import { DateSchema } from "@yamori/proto/yamori/type/v1/date_pb.js"; import { eachDayOfInterval } from "date-fns"; import { fromProtoDate, toProtoDate } from "../../../../../helpers.ts"; export function useDateCells( since: proto.MessageShape<typeof DateSchema>, until: proto.MessageShape<typeof DateSchema>, ): proto.MessageShape<typeof DateSchema>[] { return eachDayOfInterval( { start: fromProtoDate(since), end: fromProtoDate(until), }, { in: tz("Asia/Tokyo") }, ).map((datetime) => { return toProtoDate(datetime); }); }
-
-
-
@@ -1,88 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import * as proto from "@bufbuild/protobuf"; import type { Decorator, Meta, StoryObj } from "@storybook/react"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { On } from "../../../../contexts/Router.tsx"; import * as workerService from "../../../../mocks/yamori/worker/v1/worker_service.ts"; import { Page, pattern } from "./page.tsx"; function withRoute<Args>(): Decorator<Args> { return (Story) => ( <On pattern={pattern}> <Story /> </On> ); } export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace: proto.create(WorkspaceSchema, { id: { value: "ws-foo" }, displayName: "Foo Workspace", }), }, decorators: [withMockedBackend([workerService.Get()])], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/workers/wr-alice" }), ], }; export const NotFound: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "/ws-foo/workers/wr-alice/invalid" }), ], }; export const Loading: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "ws-foo/workers/wr-alice" }), withMockedBackend([workerService.Get({ delayMs: 5_000 })]), ], }; export const NonExistentWorker: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "ws-foo/workers/wr-alice" }), withMockedBackend([ workerService.Get({ failureRate: 1, error: { case: "notFound", value: {}, }, }), ]), ], }; export const LoadError: Story = { decorators: [ withRoute(), withInmemoryRouter({ initialURL: "ws-foo/workers/wr-alice" }), withMockedBackend([ workerService.Get({ failureRate: 1, }), ]), ], };
-
-
-
@@ -1,153 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { isMessage } from "@bufbuild/protobuf"; import { Button, Code, Container, Flex, Spinner, Text } from "@radix-ui/themes"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/worker/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/worker/v1/get_response_pb.js"; import { NotFoundSchema } from "@yamori/proto/yamori/error/v1/not_found_pb.js"; import { type FC } from "react"; import * as Empty from "../../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import { Select, useURLPatternResult } from "../../../../contexts/Router.tsx"; import { useMethodQuery } from "../../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { Layout } from "../../Layout.tsx"; import * as root from "./Dashboard.tsx"; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { const { pathname } = useURLPatternResult(); const workerIdRaw = pathname.groups.worker!; const query = useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, workerId: { value: workerIdRaw }, readMask: { fields: [ WorkerSchema.field.id.number, WorkerSchema.field.displayName.number, WorkerSchema.field.writeWorkRecordKey.number, WorkerSchema.field.providePaidLeaveKey.number, ], }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); if (query.isError) { if (isMessage(query.error, NotFoundSchema)) { return ( <Layout title="労働者取得失敗" workspace={workspace}> <Container px="2" size="1"> <Empty.Root> <Empty.Title>労働者が見つかりません</Empty.Title> <Empty.Description> ID が <Code>{workerIdRaw}</Code> の労働者が見つかりませんでした。 対象の労働者が既に削除されたか、打ち間違いや文字削除などにより ID が不完全の可能性があります。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={`/${workspace.id?.value}/workers`}>労働者一覧へ</a> </Button> </Empty.Actions> </Empty.Root> </Container> </Layout> ); } return ( <Layout title="労働者取得失敗" workspace={workspace}> <Container px="2" size="1"> <Empty.Root> <Empty.Title>労働者の取得に失敗</Empty.Title> <Empty.Description> 労働者を読み込もうとしましたが、データ取得に失敗しました。 </Empty.Description> <ManagedErrorCallout title="取得失敗" error={query.error} /> <Empty.Actions> <Button size="3" onClick={() => void query.refetch()}> 再取得 </Button> <Button asChild size="3" variant="outline"> <a href={`/${workspace.id?.value}/workers`}>労働者一覧へ</a> </Button> </Empty.Actions> </Empty.Root> </Container> </Layout> ); } if (!query.data) { return ( <Layout title="労働者取得中..." workspace={workspace}> <Flex position="absolute" inset="0" align="center" justify="center" gap="2"> <Spinner /> <Text size="2" color="gray"> 労働者を読込中... </Text> </Flex> </Layout> ); } const worker = query.data; return ( <Select routes={[ { pattern: root.pattern, children: <root.Page workspace={workspace} worker={worker} />, }, ]} fallback={ <Layout title="404" workspace={workspace} worker={worker}> <Empty.Root> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={root.href({ workspace, worker })}>労働者詳細へ</a> </Button> </Empty.Actions> </Empty.Root> </Layout> } /> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/workers/:worker(wr-[^\\/]+)/:flag*", });
-
-
-
@@ -1,56 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { Flex, Separator } from "@radix-ui/themes"; import { type Worker, WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC, Fragment } from "react"; import { Row } from "./Row.tsx"; export interface LoadingListProps { workspace: Workspace; workers?: undefined; loading: true; } export interface LoadedListProps { workspace: Workspace; workers: readonly Worker[]; loading?: false; } export type ListProps = LoadingListProps | LoadedListProps; const SKELETON_WORKERS: readonly Worker[] = [ create(WorkerSchema, { displayName: "あああ いい", }), create(WorkerSchema, { displayName: "ああ いい", }), create(WorkerSchema, { displayName: "あ いいいいい", }), ]; export const List: FC<ListProps> = ({ workspace, workers, loading }) => { return ( <Flex direction="column" gap="3"> {loading ? SKELETON_WORKERS.map((worker, i) => ( <Fragment key={i}> {i > 0 && <Separator size="4" />} <Row workspace={workspace} loading worker={worker} /> </Fragment> )) : workers.map((worker, i) => ( <Fragment key={worker.id?.value}> {i > 0 && <Separator size="4" />} <Row workspace={workspace} worker={worker} /> </Fragment> ))} </Flex> ); };
-
-
-
@@ -1,35 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { PersonIcon } from "@radix-ui/react-icons"; import { Avatar, Flex, Link, Skeleton } from "@radix-ui/themes"; import { type Worker } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { type FC } from "react"; import * as workerDashboard from "./:id/Dashboard.tsx"; export interface RowProps { workspace: Workspace; worker: Worker; loading?: boolean; } export const Row: FC<RowProps> = ({ workspace, worker, loading = false }) => { return ( <Flex align="center" gap="2"> <Skeleton loading={loading}> <Avatar fallback={worker.displayName[0] || <PersonIcon />} /> </Skeleton> <Skeleton loading={loading}> <Link href={workerDashboard.href({ workspace, worker })} color={worker.displayName ? undefined : "gray"} > {worker.displayName || "名称未設定"} </Link> </Skeleton> </Flex> ); };
-
-
-
@@ -1,116 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Create } from "../../../../mocks/yamori/worker/v1/worker_service.ts"; import { Get } from "../../../../mocks/yamori/workspace/v1/workspace_service.ts"; import { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, workerAddKey: { key: new Uint8Array([]) }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [withInmemoryRouter({ initialURL: "/ws-foo/workers/new" })], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([Create(), Get()])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([Create({ delayMs: 2_000 }), Get({ delayMs: 1_000 })])], }; export const NoCapability: Story = { args: { workspace: create(WorkspaceSchema, { id: { value: "ws-foo" }, }), }, decorators: [withMockedBackend([Get()])], }; export const SystemError: Story = { decorators: [withMockedBackend([Create({ delayMs: 1_000, failureRate: 1 }), Get()])], }; export const TableLoadError: Story = { decorators: [withMockedBackend([Create(), Get({ delayMs: 1_000, failureRate: 1 })])], }; export const Flaky: Story = { decorators: [ withMockedBackend([ Create({ delayMs: 1_000, failureRate: 0.5 }), Get({ delayMs: 1_000, failureRate: 0.5 }), ]), ], }; export const CapabilityError: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "capabilityError", value: { path: "worker_add_key", }, }, }), Get(), ]), ], }; export const NotFoundError: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "notFound", value: { typeName: "yamori.workspace.v1.Workspace", }, }, }), Get(), ]), ], }; export const MissingField: Story = { decorators: [ withMockedBackend([ Create({ failureRate: 1, error: { case: "missingField", value: { path: "display_name", }, }, }), Get(), ]), ], };
-
-
-
@@ -1,339 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import * as proto from "@bufbuild/protobuf"; import { TZDate } from "@date-fns/tz"; import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Container, Flex, Inset, RadioCards, Spinner, Text, TextField, } from "@radix-ui/themes"; import { PaidLeaveProvisionTableSchema } from "@yamori/proto/yamori/paid_leave_provision/v1/paid_leave_provision_table_pb.js"; import { type Workspace, WorkspaceSchema, } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { GetRequestSchema } from "@yamori/proto/yamori/workspace/v1/get_request_pb.js"; import { GetResponseSchema } from "@yamori/proto/yamori/workspace/v1/get_response_pb.js"; import { CreateRequestSchema } from "@yamori/proto/yamori/worker/v1/create_request_pb.js"; import { CreateResponseSchema } from "@yamori/proto/yamori/worker/v1/create_response_pb.js"; import { format } from "date-fns"; import { type FC, use, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import * as Empty from "../../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import * as FormField from "../../../../components/FormField.ts"; import { PaidLeaveProvisionTableView } from "../../../../components/PaidLeaveProvisionTableView.tsx"; import { useMethodMutation, useMethodQuery } from "../../../../contexts/Service.tsx"; import { NavigationContext } from "../../../../contexts/Router.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { toProtoDate } from "../../../../helpers.ts"; import { Layout } from "../../Layout.tsx"; interface BodyProps { workspace: Workspace; provisionTables: proto.MessageShape<typeof PaidLeaveProvisionTableSchema>[]; } const Body: FC<BodyProps> = ({ workspace, provisionTables }) => { const navigation = use(NavigationContext); const toast = useToast(); const sortedTables = useMemo(() => { return [...provisionTables].sort( (a, b) => (b.currentRevision?.firstProvisionAmountDays ?? 0) - (a.currentRevision?.firstProvisionAmountDays ?? 0), ); }, [provisionTables]); const firstProvisionAtDefaultValue = useMemo<string>(() => { return format(new Date(), "yyyy-MM-dd"); }, []); const form = useForm<{ displayName: string; provisionTableId: string; firstProvisionAt: string; }>({ defaultValues: { displayName: "", provisionTableId: sortedTables[0]?.id?.value, firstProvisionAt: firstProvisionAtDefaultValue, }, mode: "onBlur", }); const creation = useMethodMutation({ service: "yamori.worker.v1.WorkerService", method: "Create", request: { schema: CreateRequestSchema, }, response: { schema: CreateResponseSchema, }, mapResponse(resp) { if (resp.result.case === "ok") { if (!resp.result.value.worker) { throw new IllegalMessageError(resp); } return resp.result.value.worker; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, options: { onSuccess(worker) { toast.open({ icon: <CheckCircledIcon />, severity: "success", title: "労働者を登録しました", description: ( <>「{worker.displayName}」を労働者としてワークスペースに登録しました。</> ), type: "foreground", }); navigation.push(`/${workspace.id?.value}/workers`); }, }, }); if (!workspace.workerAddKey) { return ( <Empty.Root> <Empty.Title>権限がありません</Empty.Title> <Empty.Description> このワークスペース上に労働者を登録する権限がありません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={`/${workspace.id?.value}/workers`}>一覧へ</a> </Button> </Empty.Actions> </Empty.Root> ); } return ( <Flex asChild direction="column" gap="3" mt="2"> <form onSubmit={form.handleSubmit( ({ displayName, firstProvisionAt, provisionTableId }) => { creation.mutate({ displayName, workspaceId: workspace.id, workerAddKey: workspace.workerAddKey, firstPaidLeaveProvisionAt: toProtoDate( new TZDate(firstProvisionAt, "Asia/Tokyo"), ), paidLeaveProvisionTableId: { value: provisionTableId, }, }); }, )} > {creation.error ? ( <ManagedErrorCallout error={creation.error} title="登録に失敗しました" actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } /> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="displayName">表示名</FormField.Label> <TextField.Root disabled={creation.isPending} placeholder="日本 太郎" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { required: "表示名は必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> 労働者を表示する際に利用される名前です。前後の空白は自動的に取り除かれます。 </FormField.Description> </FormField.Root> <FormField.Root mt="4"> <FormField.Label htmlFor="first_provision_at_id"> 年次有給休暇初回付与日 </FormField.Label> <TextField.Root id="first_provision_at_id" type="date" disabled={creation.isPending} color={form.formState.errors.firstProvisionAt ? "red" : undefined} aria-invalid={!!form.formState.errors.firstProvisionAt} {...form.register("firstProvisionAt", { required: "初回付与日は必須です", })} style={{ alignSelf: "flex-start" }} /> <FormField.Description error={form.formState.errors.firstProvisionAt?.message}> この労働者に初めて年次有給休暇が付与される日付です。 以降の付与はこの日から 1 年後、 2 年後... となります。 </FormField.Description> </FormField.Root> <FormField.Root mt="4"> <FormField.Label>有給休暇付与日数</FormField.Label> <Controller control={form.control} name="provisionTableId" render={({ field }) => { return ( <RadioCards.Root {...field} disabled={creation.isPending} columns={{ initial: "1", sm: "2", md: "3", }} onValueChange={(value) => { field.onChange(value); }} > {sortedTables.map((table) => { if (!table.id) { return null; } return ( <RadioCards.Item key={table.id.value} value={table.id.value}> <Flex width="100%" direction="column" gap="2"> <Text weight="bold">{table.displayName}</Text> <Inset side="x"> <PaidLeaveProvisionTableView paidLeaveProvisionTable={table} size="1" /> </Inset> </Flex> </RadioCards.Item> ); })} </RadioCards.Root> ); }} /> <FormField.Description> 年次有給休暇の自動付与を行う際に参照する付与日数テーブルです。 </FormField.Description> </FormField.Root> <Button mt="5" loading={creation.isPending}> 作成 </Button> </form> </Flex> ); }; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { const tables = useMethodQuery({ service: "yamori.workspace.v1.WorkspaceService", method: "Get", request: { schema: GetRequestSchema, data: { workspaceId: workspace.id, readMask: { fields: [WorkspaceSchema.field.paidLeaveProvisionTables.number], paidLeaveProvisionTableMask: { fields: [ PaidLeaveProvisionTableSchema.field.id.number, PaidLeaveProvisionTableSchema.field.displayName.number, PaidLeaveProvisionTableSchema.field.currentRevision.number, ], }, }, }, }, response: { schema: GetResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value.paidLeaveProvisionTables; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); return ( <Layout workspace={workspace} title="労働者登録"> <Container p="2" size="2"> {tables.isLoading && ( <Flex mt="5" justify="center" align="center" gap="2"> <Spinner /> <Text size="2" color="gray"> 付与日数テーブルの一覧を取得中... </Text> </Flex> )} {tables.isLoadingError && ( <ManagedErrorCallout title="付与日数テーブルの一覧取得に失敗しました" error={tables.error} actions={ <Button size="1" loading={tables.isFetching} onClick={() => void tables.refetch()} > 再取得 </Button> } /> )} {tables.data && <Body workspace={workspace} provisionTables={tables.data} />} </Container> </Layout> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/workers/new", });
-
-
-
@@ -1,61 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { WorkspaceSchema } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { List } from "../../../mocks/yamori/worker/v1/worker_service.ts"; import { Page } from "./page.tsx"; const workspace = create(WorkspaceSchema, { id: { value: "ws-foo" }, }); export default { component: Page, parameters: { layout: "fullscreen", }, args: { workspace, }, decorators: [withInmemoryRouter({ initialURL: "/ws-foo/workers" })], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([List({ workspace })])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([List({ workspace, delayMs: 2_000 })])], }; export const NoWorkers: Story = { decorators: [withMockedBackend([List({ workspace, workers: [] })])], }; export const NoWorkersWithWorkerAddCapability: Story = { args: { workspace: create(WorkspaceSchema, { id: { value: "ws-foo" }, workerAddKey: { key: new Uint8Array([]), }, }), }, decorators: [withMockedBackend([List({ workspace, workers: [] })])], }; export const ListLoadError: Story = { decorators: [withMockedBackend([List({ workspace, failureRate: 1 })])], }; export const Flaky: Story = { decorators: [withMockedBackend([List({ workspace, failureRate: 0.5 })])], };
-
-
-
@@ -1,133 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { Button, Container, Flex } from "@radix-ui/themes"; import { type Workspace } from "@yamori/proto/yamori/workspace/v1/workspace_pb.js"; import { ListRequestSchema } from "@yamori/proto/yamori/worker/v1/list_request_pb.js"; import { ListResponseSchema } from "@yamori/proto/yamori/worker/v1/list_response_pb.js"; import { WorkerSchema } from "@yamori/proto/yamori/worker/v1/worker_pb.js"; import { type FC } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useMethodQuery } from "../../../contexts/Service.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { Layout } from "../Layout.tsx"; import { List } from "./List.tsx"; function useWorkerList(workspace: Workspace) { return useMethodQuery({ service: "yamori.worker.v1.WorkerService", method: "List", request: { schema: ListRequestSchema, data: { workspaceId: workspace.id!, readMask: { fields: [WorkerSchema.field.id.number, WorkerSchema.field.displayName.number], }, }, }, response: { schema: ListResponseSchema, }, mapResponse(resp) { switch (resp.result.case) { case "ok": return resp.result.value; case undefined: throw new IllegalMessageError(resp); default: throw resp.result.value; } }, }); } interface BodyProps { workspace: Workspace; workers: ReturnType<typeof useWorkerList>; } const Body: FC<BodyProps> = ({ workers, workspace }) => { if (workers.fetchStatus === "idle" && workers.data?.workers.length === 0) { return ( <Empty.Root> <Empty.Title>労働者が登録されていません</Empty.Title> <Empty.Description> ワークスペース内に労働者がまだ一人も登録されていません。 </Empty.Description> {workspace.workerAddKey && ( <Empty.Actions> <Button asChild size="3"> <a href={`/${workspace.id?.value}/workers/new`}>登録する</a> </Button> </Empty.Actions> )} </Empty.Root> ); } return ( <Flex direction="column" mt="5" gap="5"> {!!workers.error && ( <ManagedErrorCallout severity={workers.data ? "warning" : "danger"} title={workers.data ? "一覧の更新に失敗しました" : "一覧の読込に失敗しました"} error={workers.error} actions={ <Button size="1" loading={workers.isFetching} onClick={() => void workers.refetch()} > {workers.data ? "再試行" : "再取得"} </Button> } /> )} {!workers.data ? ( <List workspace={workspace} loading /> ) : ( <List workspace={workspace} workers={workers.data.workers} /> )} </Flex> ); }; export interface PageProps { workspace: Workspace; } export const Page: FC<PageProps> = ({ workspace }) => { const list = useWorkerList(workspace); return ( <Layout workspace={workspace} title="労働者一覧" actions={ <Button variant="soft" loading={list.isFetching} onClick={() => void list.refetch()} > 更新 </Button> } > <Container p="2" size="2"> <Body workspace={workspace} workers={list} /> </Container> </Layout> ); }; export const pattern = new URLPattern({ pathname: "/:workspace/workers", });
-
-
-
@@ -1,50 +0,0 @@/* * SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .appbar { border-block-end: 1px solid var(--gray-a6); background-color: var(--color-panel-solid); z-index: 50; } .menu { background-color: var(--color-background); z-index: 30; view-transition-name: menu; } .logo { display: inline-flex; padding: var(--space-1); border-radius: var(--radius-1); color: var(--gray-12); } .logo:focus-visible { color: var(--focus-11); outline: 2px solid var(--focus-a8); } @keyframes slidein { from { transform: translateX(-100%); opacity: 0; } to { transform: translateX(0px); opacity: 1; } } ::view-transition-new(menu) { animation-name: slidein; } ::view-transition-old(menu) { animation-name: slidein; animation-direction: reverse; }
-
-
-
@@ -1,77 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { Pencil2Icon, TrashIcon } from "@radix-ui/react-icons"; import { Button, IconButton } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx"; import { ThirdPartyNoticeProvider } from "../components/CopyrightNotice.ts"; import { LoggedInLayout } from "./LoggedInLayout.tsx"; export default { component: LoggedInLayout, args: { title: "TITLE", children: "CHILDREN", user: create(UserSchema, {}), }, parameters: { layout: "fullscreen", }, decorators: [ (Story) => ( <ThirdPartyNoticeProvider text="Foo"> <Story /> </ThirdPartyNoticeProvider> ), withInmemoryRouter(), ], } satisfies Meta<typeof LoggedInLayout>; type Story = StoryObj<typeof LoggedInLayout>; export const Defaults: Story = {}; export const MenuOpened: Story = { args: { defaultMenuOpened: true, }, }; export const WithTextAction: Story = { args: { actions: <Button>保存</Button>, }, }; export const WithIconActions: Story = { args: { actions: ( <> <IconButton variant="surface"> <TrashIcon /> </IconButton> <IconButton variant="surface"> <Pencil2Icon /> </IconButton> </> ), }, }; export const LongContents: Story = { args: { children: ( <ul> {Array.from({ length: 100 }, (_, i) => ( <li key={i}>Foo</li> ))} </ul> ), }, };
-
-
-
@@ -1,174 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { HamburgerMenuIcon } from "@radix-ui/react-icons"; import { Box, type BoxProps, Container, Flex, Grid, Heading, IconButton, ScrollArea, Text, } from "@radix-ui/themes"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC, type ReactNode, use, useCallback, useState } from "react"; import * as CopyrightNotice from "../components/CopyrightNotice.ts"; import { Logo } from "../components/Logo.tsx"; import * as NavigationMenu from "../components/NavigationMenu.ts"; import { URLContext } from "../contexts/Router.tsx"; import { useViewTransition } from "../hooks/useViewTransition.ts"; import * as customAttributeNew from "./custom-attribute/new/page.tsx"; import * as customAttributes from "./custom-attributes/page.tsx"; import * as userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx"; import css from "./LoggedInLayout.module.css"; export interface LoggedInLayoutProps extends Pick<BoxProps, "className" | "style"> { /** * AppBar に表示するタイトル。 */ title: ReactNode; /** * ページの主要素。 */ children?: ReactNode; /** * この画面 (シーン) にグローバルな操作・アクション。 * Flex コンテナ内となるため、 Fragment で渡すとレイアウトされる。 */ actions?: ReactNode; defaultMenuOpened?: boolean; /** * ログイン中のユーザ。 */ user: User; } export const LoggedInLayout: FC<LoggedInLayoutProps> = ({ title, children, actions, defaultMenuOpened = false, user, ...rest }) => { const viewTransition = useViewTransition(); const url = use(URLContext); const [isMenuOpened, setIsMenuOpened] = useState(defaultMenuOpened); const toggleMenu = useCallback(() => { viewTransition(() => { setIsMenuOpened((prev) => !prev); }); }, [viewTransition]); return ( <Grid {...rest} inset="0" height="100%" columns={{ initial: "1", md: "minmax(15rem, max-content) minmax(0, 1fr)" }} rows="max-content minmax(0, 1fr)" > <Flex className={css.appbar} display={{ initial: "none", md: "flex" }} align="center" justify="center" > <a className={css.logo} href="/"> <Logo /> </a> </Flex> <Flex className={css.appbar} p="2" align="center" gapX="3"> <Box display={{ md: "none" }}> <IconButton variant="soft" onClick={toggleMenu}> <HamburgerMenuIcon /> </IconButton> </Box> <Container size={{ initial: "4", md: "2", lg: "3" }}> <Flex align="center" minHeight="var(--space-6)"> <Box flexGrow="1" flexShrink="1"> <Heading size="3">{title}</Heading> </Box> {actions && ( <Flex justify="end" gapX="1"> {actions} </Flex> )} </Flex> </Container> </Flex> <Flex className={css.menu} p="2" gridRowStart="2" gridColumnStart="1" display={{ initial: isMenuOpened ? "flex" : "none", md: "flex" }} direction="column" gap="2" > <Box asChild flexGrow="1" flexShrink="1"> <ScrollArea> <NavigationMenu.Root> <NavigationMenu.Item> <span>ホーム</span> </NavigationMenu.Item> <NavigationMenu.Group title="ユーザ管理"> <NavigationMenu.Item current={users.pattern.test(url)}> <a href={users.createHref()}> <users.Title /> </a> </NavigationMenu.Item> {userNew.hasAccess(user) && ( <NavigationMenu.Item current={userNew.pattern.test(url)}> <a href={userNew.createHref()}> <userNew.Title /> </a> </NavigationMenu.Item> )} <NavigationMenu.Item current={customAttributes.pattern.test(url)}> <a href={customAttributes.createHref()}> <customAttributes.Title /> </a> </NavigationMenu.Item> {customAttributeNew.hasAccess(user) && ( <NavigationMenu.Item current={customAttributeNew.pattern.test(url)}> <a href={customAttributeNew.createHref()}> <customAttributeNew.Title /> </a> </NavigationMenu.Item> )} </NavigationMenu.Group> </NavigationMenu.Root> </ScrollArea> </Box> <Flex asChild align="center" justify="end" gap="4"> <Text size="1" color="gray"> <CopyrightNotice.ThirdParty /> <CopyrightNotice.FirstParty /> </Text> </Flex> </Flex> <Box overflowY="auto" position="relative" gridRowStart="2" gridColumnStart={{ initial: "1", md: "2" }} > {children} </Box> </Grid> ); };
-
-
-
@@ -1,68 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { userEvent } from "@storybook/test"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, PutCustomAttributeDefinition, } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, decorators: [withInmemoryRouter({ initialURL: "/custom-attribute/new" })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition()])], async play({ canvas }) { await userEvent.type(canvas.getByLabelText("表示名"), "庁舎ログインID"); await userEvent.click(canvas.getByRole("button", { name: "作成" })); }, }; export const NoPermission: Story = { args: { loginUser: bob, }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition({ delayMs: 2_000 })])], play: Success.play, }; export const SystemError: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition({ failureRate: 1 })])], play: Success.play, }; export const DuplicatedNameError: Story = { decorators: [ withMockedBackend([ PutCustomAttributeDefinition({ error: { case: "duplicatedDisplayName", value: "Foo", }, failureRate: 1, }), ]), ], play: Success.play, };
-
-
-
@@ -1,182 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { Button, Box, Container, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type PutCustomAttributeDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v2/put_custom_attribute_definition_request_pb.js"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, use } from "react"; import { useForm } from "react-hook-form"; import * as Empty from "../../../components/Empty.ts"; import * as FormField from "../../../components/FormField.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../../contexts/ConnectTransport.tsx"; import { NavigationContext } from "../../../contexts/Router.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { UserInputError } from "../../../errors/UserInputError.ts"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; export const Title: FC = () => "カスタムフィールド定義作成"; export function hasAccess(loginUser: User): boolean { return !!loginUser.permissions?.canUpdateWorkspace; } export const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>作成権限がありません</Empty.Title> <Empty.Description> ワークスペースに対する操作権限がないためページを表示できません。 </Empty.Description> </Empty.Root> ); }; interface BodyProps { onCreated?(): void; } const Body: FC<BodyProps> = ({ onCreated }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const creation = useMutation({ async mutationFn( req: MessageInitShape<typeof PutCustomAttributeDefinitionRequestSchema>, ) { const resp = await client.putCustomAttributeDefinition(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "duplicatedDisplayName") { throw new UserInputError("表示名が同一の定義が既に存在します"); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(def) { onCreated?.(); toast.open({ severity: "success", title: `カスタムフィールド定義「${def.displayName}」を作成しました`, dismissible: true, type: "foreground", }); }, }); const form = useForm<{ displayName: string; }>({ defaultValues: { displayName: "", }, mode: "onBlur", }); return ( <Flex direction="column" mt="5" gap="5"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit(({ displayName }) => { creation.mutate({ displayName, }); })} > {creation.error ? ( <Box position="sticky" top="0" pt="2"> <ManagedErrorCallout style={{ backdropFilter: "blur(2em)" }} role="alert" error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="作成に失敗しました" /> </Box> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="c_displayName">表示名</FormField.Label> <TextField.Root id="c_displayName" disabled={creation.isPending} placeholder="社員番号" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> カスタムフィールドの名前です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <Button loading={creation.isPending}>作成</Button> </form> </Flex> </Flex> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser) ? ( <Body onCreated={() => { navigation.push("/custom-attributes"); }} /> ) : ( <NoAccess /> )} </Container> </LoggedInLayout> ); }; export function createHref(): string { return "/custom-attribute/new"; } export const pattern = new URLPattern({ pathname: "/custom-attribute/new", });
-
-
-
@@ -1,79 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { userEvent } from "@storybook/test"; import { CustomAttributeDefinitionSchema } from "@yamori/proto/yamori/workspace/v2/custom_attribute_definition_pb.js"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, PutCustomAttributeDefinition, } from "../../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, createHref } from "./page.tsx"; const definition = create(CustomAttributeDefinitionSchema, { id: { value: "cf-foo", }, displayName: "Foo", }); export default { component: Page, decorators: [withInmemoryRouter({ initialURL: createHref({ definition }) })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, definition, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition()])], async play({ canvas }) { await userEvent.clear(canvas.getByLabelText("表示名")); await userEvent.type(canvas.getByLabelText("表示名"), "庁舎ログインID"); await userEvent.click(canvas.getByRole("button", { name: "更新" })); }, }; export const NoPermission: Story = { args: { loginUser: bob, }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition({ delayMs: 2_000 })])], play: Success.play, }; export const SystemError: Story = { decorators: [withMockedBackend([PutCustomAttributeDefinition({ failureRate: 1 })])], play: Success.play, }; export const DuplicatedNameError: Story = { decorators: [ withMockedBackend([ PutCustomAttributeDefinition({ error: { case: "duplicatedDisplayName", value: "Foo", }, failureRate: 1, }), ]), ], play: Success.play, };
-
-
-
@@ -1,209 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { ArrowDownIcon } from "@radix-ui/react-icons"; import { Button, Box, Container, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type CustomAttributeDefinition } from "@yamori/proto/yamori/workspace/v2/custom_attribute_definition_pb.js"; import { type PutCustomAttributeDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v2/put_custom_attribute_definition_request_pb.js"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, use } from "react"; import { useForm } from "react-hook-form"; import * as Empty from "../../../../components/Empty.ts"; import * as FormField from "../../../../components/FormField.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../../../contexts/ConnectTransport.tsx"; import { NavigationContext } from "../../../../contexts/Router.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { UserInputError } from "../../../../errors/UserInputError.ts"; import { LoggedInLayout } from "../../../LoggedInLayout.tsx"; export const Title: FC = () => "カスタムフィールド定義編集"; export function hasAccess(loginUser: User): boolean { return !!loginUser.permissions?.canUpdateWorkspace; } export const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>編集権限がありません</Empty.Title> <Empty.Description> ワークスペースに対する操作権限がないためページを表示できません。 </Empty.Description> </Empty.Root> ); }; interface BodyProps { definition: CustomAttributeDefinition; onUpdate?(): void; } const Body: FC<BodyProps> = ({ definition, onUpdate }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const creation = useMutation({ async mutationFn( req: MessageInitShape<typeof PutCustomAttributeDefinitionRequestSchema>, ) { const resp = await client.putCustomAttributeDefinition(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "duplicatedDisplayName") { throw new UserInputError("表示名が同一の定義が既に存在します"); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(def) { onUpdate?.(); toast.open({ severity: "success", title: `カスタムフィールド定義「${def.displayName}」を更新しました`, dismissible: true, type: "foreground", }); }, }); const form = useForm<{ displayName: string; }>({ defaultValues: { displayName: definition.displayName, }, mode: "onBlur", }); return ( <Flex direction="column" mt="5" gap="5"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit(({ displayName }) => { creation.mutate({ id: definition.id, displayName, }); })} > {creation.error ? ( <Box position="sticky" top="0" pt="2"> <ManagedErrorCallout style={{ backdropFilter: "blur(2em)" }} role="alert" error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="更新に失敗しました" /> </Box> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="c_displayName">表示名</FormField.Label> <Flex direction="column" gap="1"> <TextField.Root readOnly autoComplete="off" value={definition.displayName} aria-label="変更前の表示名" /> <Flex justify="center"> <ArrowDownIcon /> </Flex> <TextField.Root id="c_displayName" disabled={creation.isPending} placeholder="社員番号" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { setValueAs(value: string) { return value.trim(); }, })} /> </Flex> <FormField.Description error={form.formState.errors.displayName?.message}> カスタムフィールドの名前です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <Button loading={creation.isPending}>更新</Button> </form> </Flex> </Flex> ); }; export interface PageProps { definition: CustomAttributeDefinition; loginUser: User; } export const Page: FC<PageProps> = ({ definition, loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser) ? ( <Body definition={definition} onUpdate={() => { navigation.push("/custom-attributes"); }} /> ) : ( <NoAccess /> )} </Container> </LoggedInLayout> ); }; export interface CreateHrefInput { definition: CustomAttributeDefinition; } export function createHref({ definition }: CreateHrefInput): string { if (!definition.id) { return "/custom-attributes/"; } return `/custom-attributes/${definition.id.value}/edit`; } export const pattern = new URLPattern({ pathname: "/custom-attributes/:id/edit", });
-
-
-
@@ -1,143 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Button, Code, Container, Flex, Spinner, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../../contexts/ConnectTransport.tsx"; import { Select, useURLPatternResult } from "../../../contexts/Router.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import * as edit from "./edit/page.tsx"; import * as list from "../page.tsx"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; export const Title: FC = () => "カスタムフィールド定義"; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const { pathname } = useURLPatternResult(); const defId = pathname.groups.definition; const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const query = useQuery({ queryKey: [ WorkspaceService.typeName, WorkspaceService.method.get.name, import.meta.url, defId, ], async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") { return resp.result.value.customAttributeDefinition.find( (def) => defId === def.id?.value, ); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, }); if (query.isError) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>カスタムフィールド定義の取得に失敗</Empty.Title> <Empty.Description> カスタムフィールド定義の取得中にエラーが発生しました。 </Empty.Description> <ManagedErrorCallout title="取得失敗" error={query.error} /> <Empty.Actions> <Button size="3" onClick={() => void query.refetch()}> 再取得 </Button> <Button asChild size="3" variant="outline"> <a href={list.createHref()}>一覧画面へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } if (query.isLoading) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Flex position="absolute" inset="0" align="center" justify="center" gap="2"> <Spinner /> <Text size="2" color="gray"> カスタムフィールド定義を読込中... </Text> </Flex> </LoggedInLayout> ); } if (!query.data) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>カスタムフィールド定義がありません</Empty.Title> <Empty.Description> ID <Code>{defId}</Code> のカスタムフィールド定義は登録されていません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧画面へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } const definition = query.data; return ( <Select routes={[ { pattern: edit.pattern, children: <edit.Page definition={definition} loginUser={loginUser} />, }, ]} fallback={ <LoggedInLayout title="404" user={loginUser}> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </LoggedInLayout> } /> ); }; export const pattern = new URLPattern({ pathname: "/custom-attributes/:definition(cf-[^\\/]+)/:frag*", });
-
-
-
@@ -1,80 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button } from "@radix-ui/themes"; import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { DeleteCustomAttributeDefinition } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import * as DeleteDialog from "./DeleteDialog.tsx"; export default { component: DeleteDialog.Content, args: { id: { value: "cf-foo" }, onDeleteError: action("onDeleteError"), onDeleteStart: action("onDeleteStart"), onDeleted: action("onDeleted"), }, render(args) { return ( <DeleteDialog.Root> <DeleteDialog.Trigger> <Button data-testid="trigger">開く</Button> </DeleteDialog.Trigger> <DeleteDialog.Content {...args} /> </DeleteDialog.Root> ); }, } satisfies Meta<typeof DeleteDialog.Content>; type Story = StoryObj<typeof DeleteDialog.Content>; export const Demo: Story = { decorators: [withMockedBackend([DeleteCustomAttributeDefinition()])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); }, }; export const Success: Story = { decorators: [withMockedBackend([DeleteCustomAttributeDefinition()])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); await waitFor(() => expect(root.getByText(/削除しました/)).toBeInTheDocument()); }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([DeleteCustomAttributeDefinition({ delayMs: 2_000 })])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); }, }; export const SystemError: Story = { decorators: [ withMockedBackend([ DeleteCustomAttributeDefinition({ failureRate: 1, }), ]), ], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); await waitFor(() => expect(root.getByText(/失敗しました/)).toBeInTheDocument()); }, };
-
-
-
@@ -1,110 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { AlertDialog, Button, Flex } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type CustomAttributeDefinitionIDSchema } from "@yamori/proto/yamori/workspace/v2/custom_attribute_definition_id_pb.js"; import { type DeleteCustomAttributeDefinitionRequestSchema } from "@yamori/proto/yamori/workspace/v2/delete_custom_attribute_definition_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; export const Root = AlertDialog.Root; export const Trigger = AlertDialog.Trigger; export interface ContentProps { id: MessageInitShape<typeof CustomAttributeDefinitionIDSchema>; onDeleteStart?(): void; onDeleted?(): void; onDeleteError?(): void; } export const Content: FC<ContentProps> = ({ id, onDeleted, onDeleteError, onDeleteStart, }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const deletion = useMutation({ async mutationFn( req: MessageInitShape<typeof DeleteCustomAttributeDefinitionRequestSchema>, ) { const resp = await client.deleteCustomAttributeDefinition(req); if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(def) { toast.open({ severity: "success", title: `カスタムフィールド定義「${def.displayName}」を削除しました`, dismissible: true, type: "background", }); onDeleted?.(); }, onError(error: unknown) { // TODO: エラーをユーザに表示する (共通のエラー文字列化処理) console.error(error); toast.open({ severity: "danger", title: `カスタムフィールド定義の削除に失敗しました`, dismissible: true, type: "foreground", }); onDeleteError?.(); }, }); return ( <AlertDialog.Content maxWidth="30em"> <AlertDialog.Title>削除確認</AlertDialog.Title> <AlertDialog.Description size="2"> カスタムフィールドの定義を削除します。 ユーザに入力されているこのカスタムフィールドの値も削除されます。 <br /> この操作は取り消せません。削除しますか? </AlertDialog.Description> <Flex gap="3" mt="4" justify="end"> <AlertDialog.Cancel> <Button variant="soft" color="gray"> キャンセル </Button> </AlertDialog.Cancel> <AlertDialog.Action> <Button variant="solid" color="red" onClick={() => { deletion.mutate({ id }); onDeleteStart?.(); }} > 削除する </Button> </AlertDialog.Action> </Flex> </AlertDialog.Content> ); };
-
-
-
@@ -1,64 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, DeleteCustomAttributeDefinition, Get, } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { createHref, Page } from "./page.tsx"; export default { component: Page, parameters: { layout: "fullscreen", }, args: { loginUser: alice, }, decorators: [withInmemoryRouter({ initialURL: createHref() })], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([Get(), DeleteCustomAttributeDefinition()])], }; export const Empty: Story = { decorators: [ withMockedBackend([ Get({ workspace: { customAttributeDefinition: [], }, }), ]), ], }; export const NoMutatePermission: Story = { args: { loginUser: bob, }, decorators: [withMockedBackend([Get()])], }; export const SystemError: Story = { decorators: [withMockedBackend([Get({ failureRate: 1 })])], }; export const SlowLoad: Story = { decorators: [ withMockedBackend([ Get({ delayMs: 2_000 }), DeleteCustomAttributeDefinition({ delayMs: 2_000 }), ]), ], };
-
-
-
@@ -1,218 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Button, Container, DropdownMenu, Flex, Separator, Spinner, Text, } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, Fragment, useState } from "react"; import * as Empty from "../../components/Empty.ts"; import * as HelpDialog from "../../components/HelpDialog.ts"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import * as edit from "./:id/edit/page.tsx"; import { LoggedInLayout } from "../LoggedInLayout.tsx"; import * as DeleteDialog from "./DeleteDialog.tsx"; export const Title: FC = () => "カスタムフィールド定義一覧"; interface BodyProps { canMutate: boolean; } const Body: FC<BodyProps> = ({ canMutate }) => { const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const [isDeleting, setIsDeleting] = useState(false); const defs = useQuery({ queryKey: [ WorkspaceService.typeName, WorkspaceService.method.get.name, import.meta.url, ], async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") { return resp.result.value.customAttributeDefinition; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, }); if (!defs.data || !defs.data.length) { if (defs.error) { return ( <Empty.Root> <Empty.Title>カスタムフィールド定義の一覧を読み込めませんでした</Empty.Title> <ManagedErrorCallout error={defs.error} title="カスタムフィールド定義の一覧取得中にエラーが発生しました" /> <Empty.Actions> <Button loading={defs.isFetching} onClick={() => void defs.refetch()}> 再取得 </Button> </Empty.Actions> </Empty.Root> ); } if (defs.isFetching) { return ( <Flex mt="5" justify="center" align="center" gap="2"> <Spinner /> <Text size="2" color="gray"> 一覧を取得中... </Text> </Flex> ); } // TODO: 新規作成画面への導線 return ( <Empty.Root> <Empty.Title>カスタムフィールドは定義されていません</Empty.Title> <Empty.Description> システムにカスタムフィールドの定義は登録されていません。 </Empty.Description> <Empty.Description> 社員 ID などの属性を紐づける場合はカスタムフィールドを定義してください。 </Empty.Description> </Empty.Root> ); } return ( <Flex direction="column" mt="5" gap="5"> {defs.error && ( <ManagedErrorCallout severity="warning" title="一覧の更新取得に失敗しました" error={defs.error} actions={ <Button size="1" loading={defs.isFetching} onClick={() => void defs.refetch()} > 再試行 </Button> } /> )} <Flex direction="column" gap="4" role="list"> {defs.data.map((def, i) => { if (!def.id) { return null; } return ( <Fragment key={def.id.value}> {i > 0 && <Separator size="4" />} <DeleteDialog.Root> <DropdownMenu.Root> <Flex gap="2" align="center" justify="between" role="listitem"> <Text weight="bold">{def.displayName}</Text> {canMutate && ( <DropdownMenu.Trigger disabled={isDeleting}> <Button variant="soft"> 操作 <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> )} </Flex> <DropdownMenu.Content> <DropdownMenu.Item asChild> <a href={edit.createHref({ definition: def })}>編集</a> </DropdownMenu.Item> <DeleteDialog.Trigger> <DropdownMenu.Item color="red">削除</DropdownMenu.Item> </DeleteDialog.Trigger> </DropdownMenu.Content> <DeleteDialog.Content id={def.id} onDeleteStart={() => void setIsDeleting(true)} onDeleted={() => { setIsDeleting(false); defs.refetch(); }} onDeleteError={() => void setIsDeleting(false)} /> </DropdownMenu.Root> </DeleteDialog.Root> </Fragment> ); })} </Flex> </Flex> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { return ( <HelpDialog.Root> <LoggedInLayout title={ <Flex as="span" align="center" gap="2"> <Title /> <HelpDialog.Trigger>このページの説明</HelpDialog.Trigger> </Flex> } user={loginUser} > <Container p="2" size="2"> <Body canMutate={!!loginUser.permissions?.canUpdateWorkspace} /> </Container> </LoggedInLayout> <HelpDialog.Content> <HelpDialog.Title> <Title /> </HelpDialog.Title> <HelpDialog.Description> システムに登録されているユーザの一覧です。 </HelpDialog.Description> <HelpDialog.Paragraph> システム上で定義されているカスタムフィールドの一覧です。 ここで定義されているカスタムフィールドはユーザ情報内の項目として編集できるようになります。 </HelpDialog.Paragraph> </HelpDialog.Content> </HelpDialog.Root> ); }; export function createHref(): string { return "/custom-attributes"; } export const pattern = new URLPattern({ pathname: "/custom-attributes", });
-
-
-
@@ -1,62 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withInmemoryRouter } from "../../.storybook/decorators/withInmemoryRouter.tsx"; import { withMockedBackend } from "../../.storybook/decorators/withMockedBackend.tsx"; import { CreateUser, DeleteUser, Get, GetLoginUser, Login, PutCustomAttributeDefinition, UpdateUser, } from "../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, parameters: { layout: "fullscreen", }, decorators: [ withMockedBackend([ CreateUser(), DeleteUser(), GetLoginUser(), Login(), Get(), PutCustomAttributeDefinition(), UpdateUser(), ]), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Root: Story = { decorators: [withInmemoryRouter({ initialURL: "/" })], }; export const NotFound: Story = { decorators: [withInmemoryRouter({ initialURL: "/invalid" })], }; export const NotLoggedIn: Story = { decorators: [ withMockedBackend([ GetLoginUser({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), Login(), ]), withInmemoryRouter({ initialURL: "/invalid" }), ], };
-
-
packages/react_ui/src/pages/page.tsx (deleted)
-
@@ -1,72 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../polyfill.ts"; import { Button } from "@radix-ui/themes"; import { type FC, useState } from "react"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import * as Empty from "../components/Empty.ts"; import { Select } from "../contexts/Router.tsx"; import * as customAttributeNew from "./custom-attribute/new/page.tsx"; import * as customAttributes from "./custom-attributes/page.tsx"; import * as customAttributesId from "./custom-attributes/:id/page.tsx"; import * as userNew from "./user/new/page.tsx"; import * as users from "./users/page.tsx"; import * as usersId from "./users/:id/page.tsx"; import * as root from "./root/page.tsx"; import { LoggedInLayout } from "./LoggedInLayout.tsx"; export const Page: FC = () => { const [loginUser, setLoginUser] = useState<User | null>(null); if (!loginUser) { return <root.Page onLogin={(user) => void setLoginUser(user)} />; } return ( <Select routes={[ { pattern: userNew.pattern, children: <userNew.Page loginUser={loginUser} />, }, { pattern: usersId.pattern, children: <usersId.Page loginUser={loginUser} />, }, { pattern: users.pattern, children: <users.Page loginUser={loginUser} />, }, { pattern: customAttributeNew.pattern, children: <customAttributeNew.Page loginUser={loginUser} />, }, { pattern: customAttributesId.pattern, children: <customAttributesId.Page loginUser={loginUser} />, }, { pattern: customAttributes.pattern, children: <customAttributes.Page loginUser={loginUser} />, }, ]} fallback={ <LoggedInLayout title="404" user={loginUser}> <Empty.Root> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href="/">トップへ</a> </Button> </Empty.Actions> </Empty.Root> </LoggedInLayout> } /> ); };
-
-
-
@@ -1,58 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { CreateInitialAdmin } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { InitialAdminCreation } from "./InitialAdminCreation.tsx"; export default { component: InitialAdminCreation, decorators: [withInmemoryRouter()], } satisfies Meta<typeof InitialAdminCreation>; type Story = StoryObj<typeof InitialAdminCreation>; export const Success: Story = { decorators: [withMockedBackend([CreateInitialAdmin()])], }; export const Slow: Story = { decorators: [withMockedBackend([CreateInitialAdmin({ delayMs: 1_000 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([CreateInitialAdmin({ failureRate: 1 })])], }; export const PasswordExpired: Story = { decorators: [ withMockedBackend([ CreateInitialAdmin({ failureRate: 1, error: { case: "passwordExpired", value: {}, }, }), ]), ], }; export const AuthenticationError: Story = { decorators: [ withMockedBackend([ CreateInitialAdmin({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), ]), ], };
-
-
-
@@ -1,219 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { CheckCircledIcon } from "@radix-ui/react-icons"; import { Button, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { CreateInitialAdminRequestSchema } from "@yamori/proto/yamori/workspace/v2/create_initial_admin_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import { useForm } from "react-hook-form"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import * as FormField from "../../components/FormField.ts"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { Layout } from "./Layout.tsx"; class PasswordExpiredError extends Error { constructor() { super("Setup password is expired"); } } export interface InitialAdminCreationProps { onCreated?(): void; onPasswordExpired?(): void; } export const InitialAdminCreation: FC<InitialAdminCreationProps> = ({ onCreated, onPasswordExpired, }) => { const toast = useToast(); const form = useForm<{ name: string; displayName: string; loginPassword: string; setupPassword: string; }>({ defaultValues: { name: "", displayName: "", loginPassword: "", setupPassword: "", }, mode: "onBlur", }); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const creation = useMutation({ async mutationFn(req: MessageInitShape<typeof CreateInitialAdminRequestSchema>) { const resp = await client.createInitialAdmin(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "passwordExpired") { toast.open({ severity: "info", title: "既に管理者を作成済みです、ログインしてください", dismissible: true, type: "foreground", }); onPasswordExpired?.(); throw new PasswordExpiredError(); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onCreated?.(); toast.open({ icon: <CheckCircledIcon />, severity: "success", title: `管理者ユーザ「${user.displayName}」を作成しました`, dismissible: true, type: "foreground", }); }, }); return ( <Layout title="初期管理者作成"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit( ({ name, displayName, loginPassword, setupPassword }) => { creation.mutate({ name, displayName, password: loginPassword, initialAdminPassword: setupPassword, }); }, )} > {creation.error ? ( <ManagedErrorCallout error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="作成に失敗しました" /> ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="c_name">ユーザ名</FormField.Label> <TextField.Root id="c_name" disabled={creation.isPending} placeholder="hanako_nihon" color={form.formState.errors.name ? "red" : undefined} aria-invalid={!!form.formState.errors.name} autoComplete="username" {...form.register("name", { required: "必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.name?.message}> ログイン時に利用するユーザ名です。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_displayName">表示名</FormField.Label> <TextField.Root id="c_displayName" disabled={creation.isPending} placeholder="日本 花子" color={form.formState.errors.displayName ? "red" : undefined} aria-invalid={!!form.formState.errors.displayName} autoComplete="off" {...form.register("displayName", { setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.displayName?.message}> 他のユーザも閲覧可能な画面上の表示名です。 未指定の場合はユーザ名が設定されます。 前後の空白は無視されます。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_loginPassword"> ログインパスワード </FormField.Label> <TextField.Root id="c_loginPassword" disabled={creation.isPending} color={form.formState.errors.loginPassword ? "red" : undefined} aria-invalid={!!form.formState.errors.loginPassword} type="password" autoComplete="new-password" {...form.register("loginPassword", { required: "必須です", minLength: { value: 8, message: "8文字以上を入力してください", }, })} /> <FormField.Description error={form.formState.errors.loginPassword?.message}> 新たに作成されるユーザが利用するログインパスワードです。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_setupPassword"> 初期管理者作成パスワード </FormField.Label> <TextField.Root id="c_setupPassword" disabled={creation.isPending} color={form.formState.errors.setupPassword ? "red" : undefined} aria-invalid={!!form.formState.errors.setupPassword} type="password" autoComplete="off" {...form.register("setupPassword", { required: "必須です", })} /> <FormField.Description error={form.formState.errors.setupPassword?.message}> 管理者の初回作成用パスワードです。 システム起動時に発行されたパスワードを入力してください。 </FormField.Description> </FormField.Root> <Button loading={creation.isPending}>作成</Button> </form> </Flex> </Layout> ); };
-
-
-
@@ -1,100 +0,0 @@/* * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ .bgGrid { --_grid-thickness: 1px; --_grid-color: var(--accent-4); --_grid-bg: var(--accent-2); background-image: linear-gradient( 0deg, var(--_grid-bg), var(--_grid-bg) calc(40% - var(--_grid-thickness) / 2), transparent calc(40% - var(--_grid-thickness) / 2), transparent calc(60% + var(--_grid-thickness)), var(--_grid-bg) calc(60% + var(--_grid-thickness)), var(--_grid-bg) 100% ), linear-gradient( 90deg, var(--_grid-bg), var(--_grid-bg) calc(40% - var(--_grid-thickness) / 2), transparent calc(40% - var(--_grid-thickness) / 2), transparent calc(60% + var(--_grid-thickness)), var(--_grid-bg) calc(60% + var(--_grid-thickness)), var(--_grid-bg) 100% ), linear-gradient( 0deg, transparent calc(50% - var(--_grid-thickness) / 2), var(--_grid-color) calc(50% + var(--_grid-thickness)), transparent calc(50% + var(--_grid-thickness)), transparent 100% ), linear-gradient( 90deg, transparent calc(50% - var(--_grid-thickness) / 2), var(--_grid-color) calc(50% + var(--_grid-thickness)), transparent calc(50% + var(--_grid-thickness)), transparent 100% ); background-size: 2rem 2rem; background-color: var(--_grid-bg); } .back { view-transition-name: back-button; } .title { view-transition-name: title; } .body { view-transition-name: body; } .body[data-root] { view-transition-name: body-root; } @keyframes pull { from { opacity: 1; translate: 0 0; } to { opacity: 0; translate: -20px 0; } } ::view-transition-old(body) { animation: 0.2s ease-out both 1 pull; } ::view-transition-new(body) { animation: 0.2s 0.1s ease-in both reverse 1 pull; } @keyframes push { from { opacity: 1; translate: 0 0; } to { opacity: 0; translate: 20px 0; } } ::view-transition-old(body-root) { animation: 0.2s ease-out both 1 push; } ::view-transition-new(body-root) { animation: 0.2s 0.1s ease-in both reverse 1 push; }
-
-
-
@@ -1,97 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { Box, type BoxProps, Card, Flex, Grid, Heading, IconButton, Inset, ScrollArea, Text, Theme, } from "@radix-ui/themes"; import { type FC, type ReactNode } from "react"; import * as CopyrightNotice from "../../components/CopyrightNotice.ts"; import { Logo } from "../../components/Logo.tsx"; import css from "./Layout.module.css"; export interface LayoutProps extends Pick<BoxProps, "className" | "children" | "style"> { title: ReactNode; onBack?(): void; } export const Layout: FC<LayoutProps> = ({ className, children, title, onBack, ...rest }) => { return ( <Grid className={`${css.bgGrid} ${className || ""}`} inset="0" height="100%" p={{ initial: "2", md: "4" }} pb="2" columns={{ initial: "1", md: "minmax(0, 1fr) 30rem" }} rows={{ initial: "max-content minmax(0, 1fr) max-content", md: "minmax(0, 1fr) max-content", }} gap="2" {...rest} > <Text asChild size="6"> <Flex justify="center" align="center" py="1"> <Logo /> </Flex> </Text> <Box asChild gridColumn="-2 / -1"> <Card> <Theme asChild panelBackground="solid"> <Flex height="100%" direction="column" gap="5" p={{ initial: "0", md: "1" }}> <Flex align="center"> {onBack && ( <IconButton className={css.back} variant="ghost" onClick={() => void onBack()} > <ArrowLeftIcon /> </IconButton> )} <Box className={css.title} asChild flexGrow="1" flexShrink="1"> <Heading size="4" align="center"> {title} </Heading> </Box> </Flex> <Inset className={css.body} data-root={onBack ? "" : undefined}> <ScrollArea> <Box px={{ initial: "2", xs: "3" }} pb="3" pt="0"> {children} </Box> </ScrollArea> </Inset> </Flex> </Theme> </Card> </Box> <Flex gridColumn="-2 / -1" asChild align="center" justify="end" gap="4"> <Text size="1" color="gray"> <CopyrightNotice.ThirdParty /> <CopyrightNotice.FirstParty /> </Text> </Flex> </Grid> ); };
-
-
-
@@ -1,17 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { Loading } from "./Loading.tsx"; export default { component: Loading, decorators: [withInmemoryRouter()], } satisfies Meta<typeof Loading>; type Story = StoryObj<typeof Loading>; export const Default: Story = {};
-
-
-
@@ -1,20 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Flex, Spinner, Text } from "@radix-ui/themes"; import { type FC } from "react"; import { Layout } from "./Layout.tsx"; export const Loading: FC = () => { return ( <Layout title="ログイン"> <Flex align="center" justify="center" pt="7" gap="2"> <Spinner /> <Text size="2" color="gray"> ログイン状態取得中... </Text> </Flex> </Layout> ); };
-
-
-
@@ -1,63 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor } from "@storybook/test"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import * as mock from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Login } from "./Login.tsx"; export default { component: Login, decorators: [withInmemoryRouter()], args: { onLogin: action("onLogin"), }, } satisfies Meta<typeof Login>; type Story = StoryObj<typeof Login>; export const Success: Story = { decorators: [withMockedBackend([mock.Login()])], }; export const Slow: Story = { decorators: [withMockedBackend([mock.Login({ delayMs: 1_000 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([mock.Login({ failureRate: 1 })])], async play({ canvas }) { await userEvent.type(canvas.getByRole("textbox", { name: "ユーザ名" }), "user"); await userEvent.type(canvas.getByLabelText("パスワード"), "password"); await userEvent.click(canvas.getByRole("button", { name: "ログイン" })); await waitFor(() => expect(canvas.getByText(/システムエラー/)).toBeInTheDocument()); }, }; export const AuthenticationError: Story = { decorators: [ withMockedBackend([ mock.Login({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), ]), ], async play({ canvas }) { await userEvent.type(canvas.getByRole("textbox", { name: "ユーザ名" }), "user"); await userEvent.type(canvas.getByLabelText("パスワード"), "password"); await userEvent.click(canvas.getByRole("button", { name: "ログイン" })); await waitFor(() => expect(canvas.getByText(/が異なります/)).toBeInTheDocument()); }, };
-
-
-
@@ -1,146 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { isMessage, type MessageInitShape, type MessageShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { Button, Flex, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { AuthenticationErrorSchema } from "@yamori/proto/yamori/error/v1/authentication_error_pb.js"; import { LoginRequestSchema } from "@yamori/proto/yamori/workspace/v2/login_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC } from "react"; import { useForm } from "react-hook-form"; import { ErrorCallout, ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import * as FormField from "../../components/FormField.ts"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { Layout } from "./Layout.tsx"; export interface LoginProps { onLogin?(user: MessageShape<typeof UserSchema>): void; } export const Login: FC<LoginProps> = ({ onLogin }) => { const form = useForm<{ name: string; password: string; }>({ defaultValues: { name: "", password: "", }, mode: "onBlur", }); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const mutation = useMutation({ async mutationFn(req: MessageInitShape<typeof LoginRequestSchema>) { const resp = await client.login(req); if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onLogin?.(user); }, }); return ( <Layout title="ログイン"> <Flex asChild direction="column" gap="5" mt="2"> <form onSubmit={form.handleSubmit(({ name, password }) => { mutation.mutate({ name, password, }); })} > {mutation.error ? ( isMessage(mutation.error, AuthenticationErrorSchema) ? ( <ErrorCallout title="ログインできませんでした" actions={ <Button size="1" variant="outline" type="button" onClick={() => void mutation.reset()} > 閉じる </Button> } > ユーザ名またはパスワードが異なります。 </ErrorCallout> ) : ( <ManagedErrorCallout error={mutation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void mutation.reset()} > 閉じる </Button> } title="ログインできませんでした" /> ) ) : ( <div /> )} <FormField.Root> <FormField.Label htmlFor="c_name">ユーザ名</FormField.Label> <TextField.Root id="c_name" disabled={mutation.isPending} placeholder="hanako_nihon" color={form.formState.errors.name ? "red" : undefined} aria-invalid={!!form.formState.errors.name} autoComplete="username" {...form.register("name", { required: "必須です", setValueAs(value: string) { return value.trim(); }, })} /> <FormField.Description error={form.formState.errors.name?.message}> ログイン用のユーザ名です。 表示用の名前とは異なります。 </FormField.Description> </FormField.Root> <FormField.Root> <FormField.Label htmlFor="c_password">パスワード</FormField.Label> <TextField.Root id="c_password" disabled={mutation.isPending} color={form.formState.errors.password ? "red" : undefined} aria-invalid={!!form.formState.errors.password} type="password" autoComplete="current-password" {...form.register("password", { required: "必須です", })} /> </FormField.Root> <Button loading={mutation.isPending}>ログイン</Button> </form> </Flex> </Layout> ); };
-
-
-
@@ -1,79 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { ThirdPartyNoticeProvider } from "../../components/CopyrightNotice.ts"; import { CreateInitialAdmin, GetLoginUser, Login, } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, parameters: { layout: "fullscreen", }, args: { onLogin: action("onLogin"), }, decorators: [ withInmemoryRouter({ initialURL: "/" }), (Story) => ( <ThirdPartyNoticeProvider text="Foo"> <Story /> </ThirdPartyNoticeProvider> ), ], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const LoggedIn: Story = { decorators: [withMockedBackend([GetLoginUser()])], }; export const NotLoggedIn: Story = { decorators: [ withMockedBackend([ GetLoginUser({ failureRate: 1, error: { case: "authenticationError", value: {}, }, }), Login(), ]), ], }; export const NoAdmin: Story = { decorators: [ withMockedBackend([ GetLoginUser({ failureRate: 1, error: { case: "noUserInWorkspace", value: {}, }, }), Login(), CreateInitialAdmin(), ]), ], }; export const SlowLoad: Story = { decorators: [withMockedBackend([GetLoginUser({ delayMs: 2_000 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([GetLoginUser({ failureRate: 1 })])], };
-
-
-
@@ -1,112 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../polyfill.ts"; import { type MessageShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { useViewTransition } from "../../hooks/useViewTransition.ts"; import { InitialAdminCreation } from "./InitialAdminCreation.tsx"; import { Loading } from "./Loading.tsx"; import { Login } from "./Login.tsx"; const enum State { Loading = 0, NoAdminInWorkspace, RequireLogin, } export interface PageProps { onLogin?(user: MessageShape<typeof UserSchema>): void; } export const Page: FC<PageProps> = ({ onLogin }) => { const viewTransition = useViewTransition(); const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const [state, setState] = useState<State>(State.Loading); const onLoginRef = useRef(onLogin); onLoginRef.current = onLogin; const getLoginUser = useCallback(async () => { setState(State.Loading); try { const resp = await client.getLoginUser({}); switch (resp.result.case) { case "ok": onLoginRef.current?.(resp.result.value); return; case "noUserInWorkspace": setState(State.NoAdminInWorkspace); return; case "authenticationError": setState(State.RequireLogin); return; case "systemError": throw resp.result.value; default: throw new IllegalMessageError(resp); } } catch (err) { console.error(err); toast.open({ severity: "danger", title: "ログインユーザ情報が取得できませんでした", dismissible: true, duration: Infinity, type: "foreground", }); setState(State.RequireLogin); } }, [toast.open]); useEffect(() => { getLoginUser(); }, []); switch (state) { case State.Loading: return <Loading />; case State.NoAdminInWorkspace: return ( <InitialAdminCreation onCreated={() => { viewTransition(() => { setState(State.RequireLogin); }); }} onPasswordExpired={() => { viewTransition(() => { setState(State.RequireLogin); }); }} /> ); case State.RequireLogin: return ( <Login onLogin={(user) => { onLogin?.(user); }} /> ); } }; export const pattern = new URLPattern({ pathname: "/", });
-
-
-
@@ -1,72 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { userEvent } from "@storybook/test"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, CreateUser, } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, decorators: [withInmemoryRouter({ initialURL: "/user/new" })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([CreateUser()])], async play({ canvas }) { await userEvent.type(canvas.getByLabelText("ユーザ名"), "carol"); await userEvent.type(canvas.getByLabelText("表示名"), "Carol Curry"); await userEvent.type(canvas.getByLabelText("ログインパスワード"), "carol's password"); await userEvent.click(canvas.getByLabelText("他ユーザ情報閲覧")); await userEvent.click(canvas.getByRole("button", { name: "作成" })); }, }; export const LessPermission: Story = { decorators: [withMockedBackend([CreateUser()])], args: { loginUser: { ...alice, permissions: { ...alice.permissions!, canUpdateOtherRegularUserLoginMethod: false, canDeleteRegularUser: false, canUpdateOtherRegularUserProfile: false, canUpdateWorkspace: false, }, }, }, }; export const NoPermission: Story = { args: { loginUser: bob, }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([CreateUser({ delayMs: 2_000 })])], play: Success.play, }; export const SystemError: Story = { decorators: [withMockedBackend([CreateUser({ failureRate: 1 })])], play: Success.play, };
-
-
-
@@ -1,162 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { Button, Box, Container } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type CreateUserRequestSchema } from "@yamori/proto/yamori/workspace/v2/create_user_request_pb.js"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, use } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { CreateForm } from "../../../components/UserEditForm.tsx"; import { useConnectTransport } from "../../../contexts/ConnectTransport.tsx"; import { NavigationContext } from "../../../contexts/Router.tsx"; import { useToast } from "../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { UserInputError } from "../../../errors/UserInputError.ts"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; export const Title: FC = () => "ユーザ作成"; export function hasAccess(loginUser: User): boolean { return !!loginUser.permissions?.canAddUser; } export const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>作成権限がありません</Empty.Title> <Empty.Description> ユーザの作成権限がないためページを表示できません。 </Empty.Description> </Empty.Root> ); }; interface BodyProps { loginUser: User; onCreated?(): void; } const Body: FC<BodyProps> = ({ loginUser, onCreated }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const creation = useMutation({ async mutationFn(req: MessageInitShape<typeof CreateUserRequestSchema>) { const resp = await client.createUser(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "duplicatedName") { throw new UserInputError("ユーザ名が同一の別ユーザが既に存在します"); } if (resp.result.case === "passwordLessThanBytes") { throw new UserInputError( `${resp.result.value}文字以上のパスワードを入力してください`, ); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onCreated?.(); toast.open({ severity: "success", title: `ユーザ「${user.displayName}」を作成しました`, dismissible: true, type: "foreground", }); }, }); return ( <Box mt="3"> {creation.error ? ( <Box position="sticky" top="0" pt="2" mb="2" style={{ zIndex: 10 }}> <ManagedErrorCallout style={{ backdropFilter: "blur(2em)" }} role="alert" error={creation.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void creation.reset()} > 閉じる </Button> } title="作成に失敗しました" /> </Box> ) : ( <Box mb="2" /> )} <CreateForm pending={creation.isPending} loginUser={loginUser} onCreate={({ name, displayName, isAdmin, loginPassword, ...permissions }) => { creation.mutate({ name, displayName, isAdmin, password: loginPassword, permissions, }); }} /> </Box> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser) ? ( <Body loginUser={loginUser} onCreated={() => { navigation.push("/users"); }} /> ) : ( <NoAccess /> )} </Container> </LoggedInLayout> ); }; export function createHref(): string { return "/user/new"; } export const pattern = new URLPattern({ pathname: "/user/new", });
-
-
-
@@ -1,77 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import type { Meta, StoryObj } from "@storybook/react"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, DeleteUser, } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, createHref } from "./Details.tsx"; const user = create(UserSchema, { id: { value: "cf-foo", }, name: "foo", displayName: "Foo", }); export default { component: Page, decorators: [withInmemoryRouter({ initialURL: createHref({ user }) })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, user, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([DeleteUser()])], }; export const NoDeletePermission: Story = { args: { loginUser: bob, }, decorators: [withMockedBackend([DeleteUser()])], }; export const RegularUserViewingAdmin: Story = { args: { user: create(UserSchema, { id: { value: "cf-foo", }, name: "foo", displayName: "Foo", isAdmin: true, }), loginUser: create(UserSchema, { ...bob, permissions: { ...bob.permissions!, canReadOtherUserProfile: true, canDeleteRegularUser: true, }, }), }, decorators: [withMockedBackend([DeleteUser()])], }; export const SlowLoad: Story = { decorators: [withMockedBackend([DeleteUser({ delayMs: 2_000 })])], play: Success.play, };
-
-
-
@@ -1,93 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { Button, Container, DataList, Flex, Heading } from "@radix-ui/themes"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type FC, use } from "react"; import { NavigationContext } from "../../../contexts/Router.tsx"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; import * as DeleteDialog from "../DeleteDialog.tsx"; import * as list from "../page.tsx"; import * as edit from "./edit/page.tsx"; const Title: FC = () => "ユーザ詳細"; export interface PageProps { user: User; loginUser: User; } export const Page: FC<PageProps> = ({ user, loginUser }) => { const navigation = use(NavigationContext); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> <Flex direction="column" mt="5" gap="4"> <Heading as="h2" mt="3"> 基本情報 </Heading> <DataList.Root> <DataList.Item> <DataList.Label>ユーザ名</DataList.Label> <DataList.Value>{user.name}</DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>表示名</DataList.Label> <DataList.Value>{user.displayName}</DataList.Value> </DataList.Item> <DataList.Item> <DataList.Label>ユーザ種別</DataList.Label> <DataList.Value>{user.isAdmin ? "管理者" : "一般ユーザ"}</DataList.Value> </DataList.Item> </DataList.Root> <Heading as="h2" mt="3"> 操作 </Heading> <Flex wrap="wrap" gap="2" align="center"> <Button disabled={!edit.hasAccess(loginUser, user)} variant="soft" asChild> <a href={edit.createHref({ user })}>編集</a> </Button> <DeleteDialog.Root> <DeleteDialog.Trigger> <Button disabled={!DeleteDialog.isAvailable({ loginUser, user })} variant="soft" color="red" > 削除 </Button> </DeleteDialog.Trigger> <DeleteDialog.Content user={user} onDeleted={() => { navigation.push(list.createHref()); }} /> </DeleteDialog.Root> </Flex> </Flex> </Container> </LoggedInLayout> ); }; export interface CreateHrefInput { user: User; } export function createHref({ user }: CreateHrefInput): string { if (!user.id) { return "/users"; } return `/users/${user.id.value}`; } export const pattern = new URLPattern({ pathname: "/users/:user", });
-
-
-
@@ -1,86 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/react"; import { userEvent } from "@storybook/test"; import { withMockedBackend } from "../../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { alice, bob, UpdateUser, } from "../../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, createHref } from "./page.tsx"; export default { component: Page, decorators: [withInmemoryRouter({ initialURL: createHref({ user: bob }) })], parameters: { layout: "fullscreen", }, args: { loginUser: alice, user: bob, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([UpdateUser()])], async play({ canvas }) { await userEvent.clear(canvas.getByLabelText("表示名")); await userEvent.type(canvas.getByLabelText("表示名"), "ボビー"); await userEvent.click(canvas.getByRole("button", { name: "更新" })); }, }; export const Admin: Story = { args: { user: alice, }, decorators: [withMockedBackend([UpdateUser()])], }; export const NoPermission: Story = { args: { loginUser: bob, user: alice, }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([UpdateUser({ delayMs: 2_000 })])], play: Success.play, }; export const SystemError: Story = { decorators: [withMockedBackend([UpdateUser({ failureRate: 1 })])], play: Success.play, }; export const DuplicatedNameError: Story = { decorators: [ withMockedBackend([ UpdateUser({ error: { case: "duplicatedName", value: "Foo", }, failureRate: 1, }), ]), ], play: Success.play, }; export const RestrictedPermissions: Story = { args: { loginUser: bob, user: bob, }, decorators: [withMockedBackend([UpdateUser()])], };
-
-
-
@@ -1,181 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../../polyfill.ts"; import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { Button, Box, Container } from "@radix-ui/themes"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { type UpdateUserRequestSchema } from "@yamori/proto/yamori/workspace/v2/update_user_request_pb.js"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, use } from "react"; import * as Empty from "../../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../../components/ErrorCallout.ts"; import { UpdateForm } from "../../../../components/UserEditForm.tsx"; import { useConnectTransport } from "../../../../contexts/ConnectTransport.tsx"; import { NavigationContext } from "../../../../contexts/Router.tsx"; import { useToast } from "../../../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../../../errors/IllegalMessageError.ts"; import { UserInputError } from "../../../../errors/UserInputError.ts"; import { LoggedInLayout } from "../../../LoggedInLayout.tsx"; import { userFetchQuery } from "../page.tsx"; import * as details from "../Details.tsx"; export const Title: FC = () => "ユーザ編集"; export function hasAccess(loginUser: User, user: User): boolean { return !!(loginUser.id?.value === user.id?.value ? loginUser.permissions?.canUpdateSelfProfile : user.isAdmin ? loginUser.isAdmin : loginUser.permissions?.canUpdateOtherRegularUserProfile); } interface NoAccessProps { user: User; } const NoAccess: FC<NoAccessProps> = ({ user }) => { return ( <Empty.Root> <Empty.Title>編集権限がありません</Empty.Title> <Empty.Description> 「{user.displayName}」に対する編集権限がないためページを表示できません。 </Empty.Description> <Empty.Actions> <Button asChild> <a href={details.createHref({ user })}>ユーザ詳細画面へ</a> </Button> </Empty.Actions> </Empty.Root> ); }; interface BodyProps { loginUser: User; user: User; onUpdated?(): void; } const Body: FC<BodyProps> = ({ user, loginUser, onUpdated }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const update = useMutation({ async mutationFn(req: MessageInitShape<typeof UpdateUserRequestSchema>) { const resp = await client.updateUser(req); if (resp.result.case === "ok") { return resp.result.value; } if (resp.result.case === "duplicatedName") { throw new UserInputError("ユーザ名が同一の別ユーザが既に存在します"); } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(user) { onUpdated?.(); toast.open({ severity: "success", title: `ユーザ「${user.displayName}」を更新しました`, dismissible: true, type: "foreground", }); }, }); return ( <Box mt="3"> {update.error ? ( <Box position="sticky" top="0" pt="2" mb="2" style={{ zIndex: 10 }}> <ManagedErrorCallout style={{ backdropFilter: "blur(2em)" }} role="alert" error={update.error} actions={ <Button size="1" variant="outline" type="button" onClick={() => void update.reset()} > 閉じる </Button> } title="更新に失敗しました" /> </Box> ) : ( <Box mb="2" /> )} <UpdateForm pending={update.isPending} loginUser={loginUser} user={user} onUpdate={({ name, displayName, isAdmin, ...permissions }) => { update.mutate({ id: user.id, name, displayName, isAdmin, permissions }); }} /> </Box> ); }; export interface PageProps { loginUser: User; user: User; } export const Page: FC<PageProps> = ({ loginUser, user }) => { const navigation = use(NavigationContext); const client = useQueryClient(); return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="2" size="2"> {hasAccess(loginUser, user) ? ( <Body loginUser={loginUser} user={user} onUpdated={() => { navigation.push(details.createHref({ user })); client.refetchQueries({ queryKey: userFetchQuery(user.id?.value ?? "") }); }} /> ) : ( <NoAccess user={user} /> )} </Container> </LoggedInLayout> ); }; export interface CreateHrefInput { user: User; } export function createHref({ user }: CreateHrefInput): string { if (!user.id) { return "/users"; } return `/users/${user.id.value}/edit`; } export const pattern = new URLPattern({ pathname: "/users/:user/edit", });
-
-
-
@@ -1,74 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import type { Decorator, Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../../.storybook/decorators/withInmemoryRouter.tsx"; import { On } from "../../../contexts/Router.tsx"; import { alice, bob, Get } from "../../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page, pattern } from "./page.tsx"; function withRoute<Args>(): Decorator<Args> { return (Story) => ( <On pattern={pattern}> <Story /> </On> ); } export default { component: Page, parameters: { layout: "fullscreen", }, args: { loginUser: alice, }, } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const PageNotFound: Story = { decorators: [ withRoute(), withMockedBackend([Get()]), withInmemoryRouter({ initialURL: "/users/wu-alice/invalid" }), ], }; export const NoPermission: Story = { args: { loginUser: bob, }, decorators: [ withRoute(), withMockedBackend([Get()]), withInmemoryRouter({ initialURL: "/users/wu-alice" }), ], }; export const NotFound: Story = { decorators: [ withRoute(), withMockedBackend([Get()]), withInmemoryRouter({ initialURL: "/users/wu-foo" }), ], }; export const SystemError: Story = { decorators: [ withRoute(), withMockedBackend([Get({ failureRate: 1 })]), withInmemoryRouter({ initialURL: "/users/wu-alice" }), ], }; export const SlowLoad: Story = { decorators: [ withRoute(), withMockedBackend([Get({ delayMs: 2_000 })]), withInmemoryRouter({ initialURL: "/users/wu-alice" }), ], };
-
-
-
@@ -1,194 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Button, Code, Container, Flex, Spinner, Text } from "@radix-ui/themes"; import { type QueryKey, useQuery } from "@tanstack/react-query"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import * as Empty from "../../../components/Empty.ts"; import { ManagedErrorCallout } from "../../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../../contexts/ConnectTransport.tsx"; import { Select, useURLPatternResult } from "../../../contexts/Router.tsx"; import { IllegalMessageError } from "../../../errors/IllegalMessageError.ts"; import { LoggedInLayout } from "../../LoggedInLayout.tsx"; import * as list from "../page.tsx"; import * as edit from "./edit/page.tsx"; import * as details from "./Details.tsx"; const Title: FC = () => "ユーザ詳細"; export function hasAccess(loginUser: User, userId: string): boolean { return ( loginUser.id?.value === userId || !!loginUser.permissions?.canReadOtherUserProfile ); } const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>閲覧権限がありません</Empty.Title> <Empty.Description> 対象のユーザ情報の閲覧権限がないためページを表示できません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> ); }; export function userFetchQuery(userId: string): QueryKey { return [ WorkspaceService.typeName, WorkspaceService.method.get.name, import.meta.url, userId, ]; } interface BodyProps { loginUser: User; userId: string; } const Body: FC<BodyProps> = ({ loginUser, userId }) => { const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const query = useQuery({ queryKey: userFetchQuery(userId), async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") { // Tanstack Query が `undefined` をエラーシグナルとして濫用しているため回避が必要 // `undefined` を返すとエラー状態になる(デザインバグだが治る見込みはない) return resp.result.value.users.find((u) => u.id?.value === userId) ?? null; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, }); if (query.isError) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>ユーザ詳細の取得に失敗</Empty.Title> <Empty.Description> ユーザ詳細の取得中にエラーが発生しました。 </Empty.Description> <ManagedErrorCallout title="取得失敗" error={query.error} /> <Empty.Actions> <Button size="3" onClick={() => void query.refetch()}> 再取得 </Button> <Button asChild size="3" variant="outline"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } if (query.isLoading) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Flex position="absolute" inset="0" align="center" justify="center" gap="2"> <Spinner /> <Text size="2" color="gray"> ユーザ詳細を読込中... </Text> </Flex> </LoggedInLayout> ); } if (!query.data) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <Container p="3" size="2"> <Empty.Root> <Empty.Title>該当するユーザは存在しません</Empty.Title> <Empty.Description> ID <Code>{userId}</Code> のユーザは登録されていません。 </Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </Container> </LoggedInLayout> ); } const user = query.data; return ( <Select routes={[ { pattern: details.pattern, children: <details.Page user={user} loginUser={loginUser} />, }, { pattern: edit.pattern, children: <edit.Page user={user} loginUser={loginUser} />, }, ]} fallback={ <LoggedInLayout title="404" user={loginUser}> <Empty.Root> <Empty.Title>ページが見つかりません</Empty.Title> <Empty.Description>指定された URL にページはありません。</Empty.Description> <Empty.Actions> <Button asChild size="3"> <a href={list.createHref()}>一覧へ戻る</a> </Button> </Empty.Actions> </Empty.Root> </LoggedInLayout> } /> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { const { pathname } = useURLPatternResult(); const userId = pathname.groups.user || ""; if (!hasAccess(loginUser, userId)) { return ( <LoggedInLayout title={<Title />} user={loginUser}> <NoAccess /> </LoggedInLayout> ); } return <Body loginUser={loginUser} userId={userId} />; }; export const pattern = new URLPattern({ pathname: "/users/:user(wu-[^\\/]+)/:frag*", });
-
-
-
@@ -1,78 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { Button } from "@radix-ui/themes"; import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { DeleteUser } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import * as DeleteDialog from "./DeleteDialog.tsx"; export default { component: DeleteDialog.Content, args: { user: { id: { value: "wu-", }, }, onDeleteError: action("onDeleteError"), onDeleteStart: action("onDeleteStart"), onDeleted: action("onDeleted"), }, render(args) { return ( <DeleteDialog.Root> <DeleteDialog.Trigger> <Button data-testid="trigger">開く</Button> </DeleteDialog.Trigger> <DeleteDialog.Content {...args} /> </DeleteDialog.Root> ); }, } satisfies Meta<typeof DeleteDialog.Content>; type Story = StoryObj<typeof DeleteDialog.Content>; export const Demo: Story = { decorators: [withMockedBackend([DeleteUser()])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); }, }; export const Success: Story = { decorators: [withMockedBackend([DeleteUser()])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); await waitFor(() => expect(root.getByText(/削除しました/)).toBeInTheDocument()); }, }; export const SlowLoad: Story = { decorators: [withMockedBackend([DeleteUser({ delayMs: 2_000 })])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); }, }; export const SystemError: Story = { decorators: [withMockedBackend([DeleteUser({ failureRate: 1 })])], async play({ canvas }) { await userEvent.click(canvas.getByTestId("trigger")); const root = within(document.documentElement); await userEvent.click(root.getByRole("button", { name: /削除する/ })); await waitFor(() => expect(root.getByText(/失敗しました/)).toBeInTheDocument()); }, };
-
-
-
@@ -1,120 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { type MessageInitShape } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { AlertDialog, Button, Flex } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { type UserSchema, type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { type DeleteUserRequestSchema } from "@yamori/proto/yamori/workspace/v2/delete_user_request_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC } from "react"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { useToast } from "../../contexts/Toast.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; export interface IsAvailableInput { loginUser: User; user: User; } export function isAvailable({ loginUser, user }: IsAvailableInput): boolean { return !!( loginUser.id?.value !== user.id?.value && (user.isAdmin ? loginUser.isAdmin : loginUser.permissions?.canDeleteRegularUser) ); } export const Root = AlertDialog.Root; export const Trigger = AlertDialog.Trigger; export interface ContentProps { user: MessageInitShape<typeof UserSchema>; onDeleteStart?(): void; onDeleted?(): void; onDeleteError?(): void; } export const Content: FC<ContentProps> = ({ user, onDeleted, onDeleteError, onDeleteStart, }) => { const toast = useToast(); const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const deletion = useMutation({ async mutationFn(req: MessageInitShape<typeof DeleteUserRequestSchema>) { const resp = await client.deleteUser(req); if (resp.result.case === "ok") { return resp.result.value; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, onSuccess(def) { toast.open({ severity: "success", title: `ユーザ「${def.displayName}」を削除しました`, dismissible: true, type: "background", }); onDeleted?.(); }, onError(error: unknown) { // TODO: エラーをユーザに表示する (共通のエラー文字列化処理) console.error(error); toast.open({ severity: "danger", title: `ユーザの削除に失敗しました`, dismissible: true, type: "foreground", }); onDeleteError?.(); }, }); return ( <AlertDialog.Content maxWidth="30em"> <AlertDialog.Title>削除確認</AlertDialog.Title> <AlertDialog.Description size="2"> ユーザ「{user.displayName ?? user.name}」を削除します。 ユーザの記録も同時に消去されます。 <br /> この操作は取り消せません。削除しますか? </AlertDialog.Description> <Flex gap="3" mt="4" justify="end"> <AlertDialog.Cancel> <Button variant="soft" color="gray"> キャンセル </Button> </AlertDialog.Cancel> <AlertDialog.Action> <Button variant="solid" color="red" onClick={() => { deletion.mutate({ id: user.id }); onDeleteStart?.(); }} > 削除する </Button> </AlertDialog.Action> </Flex> </AlertDialog.Content> ); };
-
-
-
@@ -1,72 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import { create } from "@bufbuild/protobuf"; import { UserSchema } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import type { Meta, StoryObj } from "@storybook/react"; import { withMockedBackend } from "../../../.storybook/decorators/withMockedBackend.tsx"; import { withInmemoryRouter } from "../../../.storybook/decorators/withInmemoryRouter.tsx"; import { DeleteUser, Get } from "../../mocks/yamori/workspace/v2/workspace_service.ts"; import { Page } from "./page.tsx"; export default { component: Page, parameters: { layout: "fullscreen", }, args: { loginUser: create(UserSchema, { id: { value: "wu-alice" }, displayName: "Alice", permissions: { canReadOtherUserProfile: true, canDeleteRegularUser: true, }, isAdmin: false, }), }, decorators: [withInmemoryRouter({ initialURL: "/users" })], } satisfies Meta<typeof Page>; type Story = StoryObj<typeof Page>; export const Success: Story = { decorators: [withMockedBackend([Get(), DeleteUser()])], }; export const NoPermission: Story = { args: { loginUser: create(UserSchema, { id: { value: "wu-alice" }, displayName: "Alice", permissions: {}, }), }, decorators: [withMockedBackend([Get(), DeleteUser()])], }; export const NoDeletePermission: Story = { args: { loginUser: create(UserSchema, { id: { value: "wu-alice" }, displayName: "Alice", permissions: { canReadOtherUserProfile: true, canDeleteRegularUser: false, }, }), }, decorators: [withMockedBackend([Get(), DeleteUser({ failureRate: 1 })])], }; export const SystemError: Story = { decorators: [withMockedBackend([Get({ failureRate: 1 }), DeleteUser()])], }; export const SlowLoad: Story = { decorators: [ withMockedBackend([Get({ delayMs: 2_000 }), DeleteUser({ delayMs: 3_000 })]), ], };
-
-
-
@@ -1,258 +0,0 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "../../polyfill.ts"; import { createClient } from "@connectrpc/connect"; import { Badge, Button, Container, DropdownMenu, Flex, Separator, Spinner, Text, } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { type User } from "@yamori/proto/yamori/workspace/v2/user_pb.js"; import { WorkspaceService } from "@yamori/proto/yamori/workspace/v2/workspace_service_pb.js"; import { type FC, Fragment, useState } from "react"; import * as Empty from "../../components/Empty.ts"; import * as HelpDialog from "../../components/HelpDialog.ts"; import { ManagedErrorCallout } from "../../components/ErrorCallout.ts"; import { useConnectTransport } from "../../contexts/ConnectTransport.tsx"; import { IllegalMessageError } from "../../errors/IllegalMessageError.ts"; import { LoggedInLayout } from "../LoggedInLayout.tsx"; import * as userDetails from "./:id/Details.tsx"; import * as userEdit from "./:id/edit/page.tsx"; import * as DeleteDialog from "./DeleteDialog.tsx"; export const Title: FC = () => "ユーザ一覧"; export function hasAccess(loginUser: User): boolean { return !!loginUser.permissions?.canReadOtherUserProfile; } export const NoAccess: FC = () => { return ( <Empty.Root> <Empty.Title>閲覧権限がありません</Empty.Title> <Empty.Description> 他のユーザの閲覧権限がないためページを表示できません。 </Empty.Description> </Empty.Root> ); }; interface BodyProps { loginUser: User; } export const Body: FC<BodyProps> = ({ loginUser }) => { const transport = useConnectTransport(); const client = createClient(WorkspaceService, transport); const [isDeleting, setIsDeleting] = useState(false); const users = useQuery({ queryKey: [ WorkspaceService.typeName, WorkspaceService.method.get.name, import.meta.url, ], async queryFn() { const resp = await client.get({}); if (resp.result.case === "ok") { return resp.result.value.users; } if (typeof resp.result.case === "string") { throw resp.result.value; } throw new IllegalMessageError(resp); }, }); if (!users.data) { if (users.error) { return ( <Empty.Root> <Empty.Title>ユーザ一覧を読み込めませんでした</Empty.Title> <ManagedErrorCallout error={users.error} title="ユーザ一覧を取得中にエラーが発生しました" /> <Empty.Actions> <Button loading={users.isFetching} onClick={() => void users.refetch()}> 再取得 </Button> </Empty.Actions> </Empty.Root> ); } if (users.isFetching) { return ( <Flex mt="5" justify="center" align="center" gap="2"> <Spinner /> <Text size="2" color="gray"> 一覧を取得中... </Text> </Flex> ); } return ( <Empty.Root> <Empty.Title>ユーザが存在しません</Empty.Title> <Empty.Description> ユーザ一覧のデータが空です。 システム不整合のため管理者に連絡してください。 </Empty.Description> </Empty.Root> ); } return ( <Flex direction="column" mt="5" gap="5"> {users.error && ( <ManagedErrorCallout severity="warning" title="一覧の更新取得に失敗しました" error={users.error} actions={ <Button size="1" loading={users.isFetching} onClick={() => void users.refetch()} > 再試行 </Button> } /> )} <Flex direction="column" gap="4" role="list"> {users.data.map((user, i) => { const isLoginUser = loginUser.id?.value === user.id?.value; return ( <Fragment key={user.id?.value}> {i > 0 && <Separator size="4" />} <DeleteDialog.Root> <DropdownMenu.Root> <Flex gap="2" align="center" justify="between" role="listitem"> <Flex direction="column" gap="1"> <Text weight="bold">{user.displayName}</Text> <Flex gap="1" align="center" wrap="wrap" justify="start" role="list" > {user.isAdmin && <Badge color="red">管理者</Badge>} {isLoginUser && <Badge color="blue">ログイン中</Badge>} </Flex> </Flex> <DropdownMenu.Trigger disabled={users.isFetching}> <Button variant="soft"> 操作 <DropdownMenu.TriggerIcon /> </Button> </DropdownMenu.Trigger> </Flex> <DropdownMenu.Content> <DropdownMenu.Item asChild> <a href={userDetails.createHref({ user })}>詳細</a> </DropdownMenu.Item> <DropdownMenu.Item asChild disabled={ !userEdit.hasAccess(loginUser, user) || users.isFetching || isDeleting } > <a href={userEdit.createHref({ user })}>編集</a> </DropdownMenu.Item> <DeleteDialog.Trigger> <DropdownMenu.Item color="red" disabled={ !DeleteDialog.isAvailable({ loginUser, user }) || users.isFetching || isDeleting } > 削除 </DropdownMenu.Item> </DeleteDialog.Trigger> </DropdownMenu.Content> </DropdownMenu.Root> <DeleteDialog.Content user={user} onDeleteStart={() => void setIsDeleting(true)} onDeleted={() => { setIsDeleting(false); users.refetch(); }} onDeleteError={() => void setIsDeleting(false)} /> </DeleteDialog.Root> </Fragment> ); })} </Flex> </Flex> ); }; export interface PageProps { loginUser: User; } export const Page: FC<PageProps> = ({ loginUser }) => { return ( <HelpDialog.Root> <LoggedInLayout title={ <Flex as="span" align="center" gap="2"> <Title /> <HelpDialog.Trigger>このページの説明</HelpDialog.Trigger> </Flex> } user={loginUser} > <Container p="2" size="2"> {hasAccess(loginUser) ? <Body loginUser={loginUser} /> : <NoAccess />} </Container> </LoggedInLayout> <HelpDialog.Content> <HelpDialog.Title> <Title /> </HelpDialog.Title> <HelpDialog.Description> システムに登録されているユーザの一覧です。 </HelpDialog.Description> <HelpDialog.Paragraph> システムに登録されているすべてのユーザが、ログインの可能・不可能に関わらず表示されます。 他のユーザの閲覧権限がない場合は利用できません。 </HelpDialog.Paragraph> </HelpDialog.Content> </HelpDialog.Root> ); }; export function createHref(): string { return "/users"; } export const pattern = new URLPattern({ pathname: "/users", });
-
-
packages/react_ui/src/polyfill.ts (deleted)
-
@@ -1,4 +0,0 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import "urlpattern-polyfill";
-
-
-
@@ -1,11 +0,0 @@/* * Radix UI でバグが修正されるまでの暫定対処。 * * SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> * SPDX-License-Identifier: AGPL-3.0-only */ /* <https://github.com/radix-ui/themes/issues/527> */ input[type="date"].rt-reset { white-space: unset; }
-
-
packages/react_ui/tsconfig.build.jsonc (deleted)
-
@@ -1,16 +0,0 @@// types/*.d.ts をビルドする際の設定。 // // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, "declaration": true, "incremental": true, "emitDeclarationOnly": true, "outDir": "./types" }, "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["src/mocks", "src/**/*.stories.tsx"] }
-
-
packages/react_ui/tsconfig.json (deleted)
-
@@ -1,18 +0,0 @@{ "extends": "../../tsconfig.jsonc", "compilerOptions": { "moduleResolution": "Bundler", "moduleDetection": "force", "noEmit": true, "lib": ["ES2022", "DOM", "DOM.iterable"], "types": ["vite/client", "bun"], "jsx": "react-jsx" }, "include": [ "*.ts", ".storybook/**/*.ts", ".storybook/**/*.tsx", "src/**/*.ts", "src/**/*.tsx" ] }
-
-
packages/react_ui/tsconfig.json.license (deleted)
-
@@ -1,9 +0,0 @@TypeScript のコンパイラ設定ファイル。 実際は JSON ではなく JSONC のためコメントを含められるが、 .jsonc にすると TypeScript 関連のツールがデフォルトで読まなくなる。逆に .json のまま コメントを含めるとフォーマッタといった TypeScript 以外の JSON を扱うツール でエラーになる。そのため拡張子と仕様に準拠している。 SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
-
-
packages/react_ui/vite.config.ts (deleted)
-
@@ -1,35 +0,0 @@// Web 向けのバンドラー、 Vite の設定ファイル。 // <https://vite.dev/> // // SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ build: { lib: { entry: "src/lib.ts", formats: ["es"], fileName: "lib", cssFileName: "styles", }, rollupOptions: { external: [ "date-fns", /^@date-fns\//, "react", "react-dom", /^react\//, /^@radix-ui\//, "react-hook-form", /^@tanstack\//, /^@yamori\//, /^@bufbuild\//, "urlpattern-polyfill", ], }, }, plugins: [react()], });
-
-
vendor/go-sqlite3-js/.dockerignore (deleted)
-
@@ -1,1 +0,0 @@node_modules
-
-
vendor/go-sqlite3-js/.gitignore (deleted)
-
@@ -1,4 +0,0 @@.DS_Store main.wasm node_modules yarn.lock
-
-
vendor/go-sqlite3-js/.golangci.yml (deleted)
-
@@ -1,21 +0,0 @@run: timeout: 5m linters: enable: - vet - vetshadow - typecheck - deadcode - gocyclo - golint - varcheck - structcheck - maligned - ineffassign - misspell - unparam - goimports - goconst - unconvert - errcheck - interfacer
-
-
vendor/go-sqlite3-js/Dockerfile (deleted)
-
@@ -1,18 +0,0 @@FROM golang:1.13-stretch # Install node and yarn RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - RUN apt-get update && apt-get install -y nodejs RUN npm install -g yarn # Download golangci-lint and sql.js WORKDIR /test RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.24.0 ENV GOOS js ENV GOARCH wasm ADD package.json . RUN yarn install # Run the tests ADD . . CMD go test -exec="./go_sqlite_js_wasm_exec" .
-
-
vendor/go-sqlite3-js/LICENSE (deleted)
-
@@ -1,177 +0,0 @@Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS
-
-
vendor/go-sqlite3-js/README.md (deleted)
-
@@ -1,18 +0,0 @@### go-sqlite3-js Experimental SQL driver for sql.js (in-browser sqlite) from Go WASM. Only implements the subset of the SQL API required by Dendrite. To run tests in Docker and Node: ``` $ docker build -t gsj . $ docker run gsj ``` To run tests locally: ```bash $ yarn install $ GOOS=js GOARCH=wasm go test -exec="./go_sqlite_js_wasm_exec" . ```
-
-
vendor/go-sqlite3-js/connection.go (deleted)
-
@@ -1,114 +0,0 @@package sqlite3_js //nolint:golint import ( "context" "database/sql/driver" "fmt" "strings" "sync" "syscall/js" ) // SqliteJsConn implements driver.Conn. type SqliteJsConn struct { JsDb js.Value // sql.js SQL.Database : https://sql-js.github.io/sql.js/documentation/class/Database.html mu *sync.Mutex } // Prepare creates a prepared statement for later queries or executions. Multiple // queries or executions may be run concurrently from the returned statement. The // caller must call the statement's Close method when the statement is no longer // needed. func (conn *SqliteJsConn) Prepare(query string) (stmt driver.Stmt, err error) { defer protect("Prepare", func(e error) { err = e }) return &SqliteJsStmt{ c: conn, js: conn.JsDb.Call("prepare", query), }, nil } // Close returns the connection to the connection pool. All operations after a // Close will return with ErrConnDone. Close is safe to call concurrently with // other operations and will block until all other operations finish. It may be // useful to first cancel any used context and then call close directly after. func (conn SqliteJsConn) Close() error { // TODO return nil } func (conn *SqliteJsConn) Exec(query string, args []driver.Value) (result driver.Result, err error) { defer protect("Exec", func(e error) { err = e }) query = strings.TrimRight(query, ";") if strings.Contains(query, ";") { if len(args) != 0 { return nil, fmt.Errorf("cannot exec multiple statements with placeholders, query: %s nargs=%d", query, len(args)) } jsVal, err := jsTryCatch(func() js.Value { return conn.JsDb.Call("exec", query) }) if err != nil { return nil, err } return &SqliteJsResult{ js: jsVal, changes: 0, id: 0, }, nil } list := make([]namedValue, len(args)) for i, v := range args { list[i] = namedValue{ Ordinal: i + 1, Value: v, } } return conn.exec(context.Background(), query, list) } func (conn *SqliteJsConn) exec(ctx context.Context, query string, args []namedValue) (driver.Result, error) { // FIXME: we removed tbe ability to handle 'tails' - is this a problem? s, err := conn.Prepare(query) if err != nil { return nil, err } var res driver.Result res, err = s.(*SqliteJsStmt).exec(ctx, args) s.Close() return res, err } // Transactions // Begin starts a transaction. The default isolation level is dependent on the driver. func (conn *SqliteJsConn) Begin() (driver.Tx, error) { return conn.begin(context.Background()) } // BeginTx starts and returns a new transaction. // If the context is canceled by the user the sql package will // call Tx.Rollback before discarding and closing the connection. // // This must check opts.Isolation to determine if there is a set // isolation level. If the driver does not support a non-default // level and one is set or if there is a non-default isolation level // that is not supported, an error must be returned. // // This must also check opts.ReadOnly to determine if the read-only // value is true to either set the read-only transaction property if supported // or return an error if it is not supported. func (conn *SqliteJsConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { return conn.begin(ctx) } func (conn *SqliteJsConn) begin(ctx context.Context) (driver.Tx, error) { //nolint:unparam /* if conn.disableTxns { fmt.Println("Ignoring BEGIN, txns disabled") return &SqliteJsTx{c: conn}, nil } if _, err := conn.exec(ctx, "BEGIN", nil); err != nil { return nil, err } */ return &SqliteJsTx{c: conn}, nil }
-
-
vendor/go-sqlite3-js/go.mod (deleted)
-
@@ -1,3 +0,0 @@module github.com/matrix-org/go-sqlite3-js go 1.13
-
-
-
@@ -1,2 +0,0 @@#!/bin/bash exec node "./wasm_exec.js" "$@"
-
-
vendor/go-sqlite3-js/js.go (deleted)
-
@@ -1,56 +0,0 @@package sqlite3_js //nolint:golint import ( "fmt" "os" "runtime/debug" "syscall/js" ) const ( // The name of the global where sql.js has been loaded. This is the `SQL` var of: // const initSqlJs = require('sql.js'); // const SQL = await initSqlJs({ ...}) globalSQLJS = "_go_sqlite" // The name of the global where sql.js should store its databases. This is purely // for debugging as JS-land doesn't ever read this map, but Go stores databases here. // The value of this global is an empty Map. It is crucial this isn't an empty object // else database names like 'hasOwnProperty' will fail due to it existing but // not being a database object! globalSQLDBs = "_go_sqlite_dbs" ) // jsEnsureGlobal is a helper function to set-if-not-exists and return whether the global existed. func jsEnsureGlobal(globalName string, defaultVal *js.Value) (existed bool) { v := js.Global().Get(globalName) if v.Truthy() { return true } if defaultVal != nil { js.Global().Set(globalName, *defaultVal) v = *defaultVal } return false } // jsTryCatch is a helper function that catches exceptions/panics thrown by fn and returns them as error. // This is useful for calling JS functions which can throw. func jsTryCatch(fn func() js.Value) (val js.Value, err error) { defer func() { if e := recover(); e != nil { err = fmt.Errorf("exception: %s", e) } }() return fn(), nil } // protect is a helper function which guards against panics, setting an error when it happens. func protect(name string, setError func(error)) { err := recover() if err != nil { fmt.Fprintf(os.Stderr, "%s panicked: %s\n", name, err) debug.PrintStack() setError(fmt.Errorf("%s panicked: %s", name, err)) } }
-
-
vendor/go-sqlite3-js/package.json (deleted)
-
@@ -1,10 +0,0 @@{ "name": "go-sqlite-js", "version": "0.1.0", "description": "go SQL driver for sql.js from go-wasm", "main": "js/index.js", "license": "Apache 2.0", "dependencies": { "sql.js": "^1.6.2" } }
-
-
vendor/go-sqlite3-js/sql.js (deleted)
-
@@ -1,23 +0,0 @@// -*- coding: utf-8 -*- // Copyright 2020 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import initSqlJs from 'sql.js' // Load sql.js into a global var which Go will use export function init(config) { return initSqlJs(config).then(SQL => { global._go_sqlite = SQL; }); }
-
-
vendor/go-sqlite3-js/sqlite3.go (deleted)
-
@@ -1,245 +0,0 @@// -*- coding: utf-8 -*- // Copyright 2020 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Derived from https://github.com/mattn/go-sqlite3 package sqlite3_js //nolint:golint import ( "context" "database/sql" "database/sql/driver" "fmt" "io" "log" "strconv" "strings" "sync" "syscall/js" ) func init() { sql.Register("sqlite3", &SqliteJsDriver{}) dbMap := js.Global().Get("Map").New() jsEnsureGlobal(globalSQLDBs, &dbMap) exists := jsEnsureGlobal(globalSQLJS, nil) if !exists { panic(globalSQLJS + " must be set a global variable in JS") } } // SqliteJsDriver implements driver.Driver. type SqliteJsDriver struct { ConnectHook func(*SqliteJsConn) error } // SqliteJsTx implements driver.Tx. type SqliteJsTx struct { c *SqliteJsConn } // SqliteJsResult implements sql.Result. type SqliteJsResult struct { js js.Value id int64 changes int64 } // SqliteJsRows implements driver.Rows. type SqliteJsRows struct { s *SqliteJsStmt // nc int // cols []string // decltype []string closed bool cls bool ctx context.Context // no better alternative to pass context into Next() method } // Open a database "connection" to a SQLite database. func (d *SqliteJsDriver) Open(dsn string) (conn driver.Conn, err error) { dsn = strings.TrimPrefix(dsn, "file:") defer protect("Open", func(e error) { err = e }) dbMap := js.Global().Get(globalSQLDBs) jsDb := dbMap.Call("get", dsn) if !jsDb.Truthy() { jsDb = js.Global().Get(globalSQLJS).Get("Database").New() dbMap.Call("set", dsn, jsDb) } fmt.Println("Open ->", dsn, "err=", err) return &SqliteJsConn{ JsDb: jsDb, mu: &sync.Mutex{}, }, nil } // Commit commits the transaction. func (tx *SqliteJsTx) Commit() error { return nil /* if tx.c.disableTxns { fmt.Println("Ignoring COMMIT, txns disabled") return nil } _, err := tx.c.exec(context.Background(), "COMMIT", nil) if err != nil { // FIXME: ideally should only be called when // && err.(Error).Code == C.SQLITE_BUSY // // sqlite3 will leave the transaction open in this scenario. // However, database/sql considers the transaction complete once we // return from Commit() - we must clean up to honour its semantics. tx.c.exec(context.Background(), "ROLLBACK", nil) } return err */ } // Rollback aborts the transaction. func (tx *SqliteJsTx) Rollback() error { return nil /* if tx.c.disableTxns { fmt.Println("Ignoring ROLLBACK, txns disabled") return nil } _, err := tx.c.exec(context.Background(), "ROLLBACK", nil) return err */ } // Rows // Columns returns the names of the columns. The number of // columns of the result is inferred from the length of the // slice. If a particular column name isn't known, an empty // string should be returned for that entry. func (r *SqliteJsRows) Columns() []string { res := r.s.js.Call("getColumnNames") cols := make([]string, res.Length()) for i := 0; i < res.Length(); i++ { cols[i] = res.Get(strconv.Itoa(i)).String() } return cols } // Next is called to populate the next row of data into // the provided slice. The provided slice will be the same // size as the Columns() are wide. // // Next should return io.EOF when there are no more rows. // // The dest should not be written to outside of Next. Care // should be taken when closing Rows not to modify // a buffer held in dest. func (r *SqliteJsRows) Next(dest []driver.Value) error { r.s.mu.Lock() defer r.s.mu.Unlock() if r.s.closed { return io.EOF } if r.ctx.Done() == nil { return r.nextSyncLocked(dest) } resultCh := make(chan error) go func() { defer func() { if perr := recover(); perr != nil { fmt.Printf("SqliteJsRows.Next panicked! err=%s", perr) resultCh <- fmt.Errorf("SqliteJsRows.Next panicked! err=%s", perr) } }() resultCh <- r.nextSyncLocked(dest) }() select { case err := <-resultCh: return err case <-r.ctx.Done(): select { case <-resultCh: // no need to interrupt default: // this is still racy and can be no-op if executed between sqlite3_* calls in nextSyncLocked. // FIXME: find a way to interrupt // C.sqlite3_interrupt(rc.s.c.db) <-resultCh // ensure goroutine completed } return r.ctx.Err() } } // nextSyncLocked moves cursor to next; must be called with locked mutex. func (r *SqliteJsRows) nextSyncLocked(dest []driver.Value) error { rr := r.s.Next() if rr == nil { return io.EOF } res := *rr for i := 0; i < res.Length(); i++ { jsVal := res.Get(strconv.Itoa(i)) switch t := jsVal.Type(); t { case js.TypeNull: dest[i] = nil case js.TypeBoolean: dest[i] = jsVal.Bool() case js.TypeNumber: dest[i] = jsVal.Int() case js.TypeString: dest[i] = jsVal.String() case js.TypeSymbol: log.Fatal("Don't know how to handle Symbols yet") case js.TypeObject: // check for []byte if jsVal.Get("byteLength").Truthy() { uint8slice := make([]uint8, jsVal.Get("byteLength").Int()) js.CopyBytesToGo(uint8slice, jsVal) dest[i] = uint8slice } else { log.Fatal("Don't know how to handle Objects yet") } case js.TypeFunction: log.Fatal("Don't know how to handle Functions yet") } } return nil } // Close closes the rows iterator. func (r *SqliteJsRows) Close() error { r.s.mu.Lock() defer r.s.mu.Unlock() if r.s.closed || r.closed { return nil } r.closed = true if r.cls { return r.s.Close() } r.s.js.Call("reset") return nil } // Results // LastInsertId return last inserted ID. func (r *SqliteJsResult) LastInsertId() (int64, error) { return r.id, nil } // RowsAffected return how many rows affected. func (r *SqliteJsResult) RowsAffected() (int64, error) { return r.changes, nil }
-
-
vendor/go-sqlite3-js/sqlite3_test.go (deleted)
-
@@ -1,369 +0,0 @@// Copyright 2020 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sqlite3_js_test import ( "context" "crypto/sha256" "database/sql" "fmt" "testing" _ "github.com/matrix-org/go-sqlite3-js" ) var i = 1 func newDB(t *testing.T, schema string) *sql.DB { var db *sql.DB var err error i++ if db, err = sql.Open("sqlite3_js", fmt.Sprintf("test-%d.db", i)); err != nil { t.Fatalf("cannot open test.db: %s", err) } _, err = db.Exec(schema) if err != nil { t.Fatalf("cannot create schema: %s", err) } return db } func assertStored(t *testing.T, db *sql.DB, query string, wants []string) { //nolint:unparam rows, err := db.Query(query) if err != nil { t.Fatalf("assertStored: cannot run query: %s", err) } defer rows.Close() var gots []string for rows.Next() { var got string if err := rows.Scan(&got); err != nil { t.Fatalf("assertStored: failed to scan row: %s", err) } gots = append(gots, got) } if len(gots) != len(wants) { t.Fatalf("assertStored: got %d results, want %d", len(gots), len(wants)) } for i := range wants { if gots[i] != wants[i] { t.Errorf("assertStored: result row %d got %s, want %s", i, gots[i], wants[i]) } } } func TestEmptyQuery(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") stmt, err := db.Prepare("SELECT id, name FROM foo") if err != nil { t.Fatal(err) } // querying an empty table shouldn't produce an error rows, err := stmt.Query() if err != nil { t.Fatal(err) } rows.Close() } func TestEmptyStmtQueryWithResults(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") wantIDs := []int{11, 12, 13} wantNames := []string{"Eleven", "Mike", "Dustin"} for i := range wantIDs { _, err := db.Exec("INSERT INTO foo VALUES(?,?)", wantIDs[i], wantNames[i]) if err != nil { t.Fatalf("Insert failed: %s", err) } } stmt, err := db.Prepare("SELECT id, name FROM foo") if err != nil { t.Fatal(err) } rows, err := stmt.Query() if err != nil { t.Fatal(err) } defer rows.Close() i := 0 for rows.Next() { var id int var name string err := rows.Scan(&id, &name) if err != nil { t.Fatalf("Scan failed: %s", err) } if id != wantIDs[i] { t.Errorf("Row %d: got ID %d, want %d", i, id, wantIDs[i]) } if name != wantNames[i] { t.Errorf("Row %d: got name %s, want %s", i, name, wantNames[i]) } i++ } if len(wantIDs) != i { t.Errorf("Mismatched number of returned rows: %d != %d", len(wantIDs), i) } } func TestErrNoRows(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") stmt, err := db.Prepare("SELECT id, name FROM foo") if err != nil { t.Fatal(err) } // query row context should return ErrNoRows var a int64 var b string err = stmt.QueryRowContext(context.Background()).Scan(&a, &b) if err != sql.ErrNoRows { t.Fatalf("Expected sql.ErrNoRows to QueryRowContext, got %s", err) } } func TestMultipleConnSupport(t *testing.T) { // We check this by doing multiple txns at once. If the same conn is used, // we'll error out with: // sql.js: cannot start a transaction within a transaction // Dendrite only does this once, then calls a bunch of stuff db := newDB(t, "CREATE TABLE foo(id INTEGER)") tx, err := db.Begin() if err != nil { t.Fatal(err) } _, err = tx.Exec("CREATE TABLE bar(id INTEGER)") if err != nil { t.Fatalf("tx1 exec failed: %s", err) } // begin a 2nd txn without closing the 1st tx2, err := db.Begin() if err != nil { t.Fatal(err) } _, err = tx2.Exec("CREATE TABLE baz(id INTEGER)") if err != nil { t.Fatalf("tx2 exec failed: %s", err) } if err = tx2.Commit(); err != nil { t.Fatalf("tx2 commit failed: %s", err) } if err = tx.Commit(); err != nil { t.Fatalf("tx1 commit failed: %s", err) } } func TestBlobSupport(t *testing.T) { db := newDB(t, "create table blobs(id INTEGER, thing BLOB)") blobStmt, err := db.Prepare("INSERT INTO blobs(id, thing) values($1, $2)") if err != nil { t.Fatal(err) } rawBytes := sha256.Sum256([]byte("hello world")) _, err = blobStmt.Exec(44, rawBytes[:]) if err != nil { t.Fatal(err) } blobSelectStmt, err := db.Prepare("SELECT thing FROM blobs WHERE id = $1") if err != nil { t.Fatal(err) } var bres []byte if err := blobSelectStmt.QueryRow(44).Scan(&bres); err != nil { t.Fatal(err) } if len(bres) != len(rawBytes) { t.Fatalf("Mismatched lengths: got %d want %d", len(bres), len(rawBytes)) } for i := range bres { if bres[i] != rawBytes[i] { t.Fatalf("Wrong value at pos %d/%d: got %d want %d", i, len(bres), bres[i], rawBytes[i]) } } t.Log("OK: checked ", len(bres), " bytes") } func TestInsertNull(t *testing.T) { db := newDB(t, "create table bar(id INTEGER PRIMARY KEY, name string)") res, err := db.Exec("insert into bar values(9001, NULL)") if err != nil { t.Fatal(err) } if ra, _ := res.RowsAffected(); ra != 1 { t.Fatalf("expected 1 row affected, got %d", ra) } } func TestInsertPrimaryKeyConflict(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") _, err := db.Exec("insert into foo values(42, 'meaning of life')") if err != nil { t.Fatal(err) } _, err = db.Exec("insert into foo values(42, 'meaning of life')") if err == nil { t.Fatal("Expected error, got nil") } } func TestUpdate(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") _, err := db.Exec("insert into foo values(42, 'meaning of life')") if err != nil { t.Fatalf("failed to insert: %s", err) } assertStored(t, db, "SELECT name FROM foo", []string{"meaning of life"}) _, err = db.Exec("UPDATE foo SET name='mol' WHERE name='meaning of life'") if err != nil { t.Fatalf("failed to update: %s", err) } assertStored(t, db, "SELECT name FROM foo", []string{"mol"}) } func TestParameterisedInsert(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") _, err := db.Exec("insert into foo values(?, ?)", 31337, "so leet") if err != nil { t.Fatal(err) } assertStored(t, db, "SELECT name FROM foo", []string{"so leet"}) } func TestParameterisedStmtExec(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") var stmt *sql.Stmt var err error if stmt, err = db.Prepare("insert into foo values(?, ?)"); err != nil { t.Fatal(err) } _, err = stmt.Exec(12345678, "monotonic") if err != nil { t.Fatalf("Failed exec: %s", err) } assertStored(t, db, "SELECT name FROM foo", []string{"monotonic"}) } func TestCommit(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") var txn *sql.Tx var stmt *sql.Stmt var err error if txn, err = db.Begin(); err != nil { t.Fatalf("begin failed: %s", err) } if stmt, err = db.Prepare("insert into foo values(?, ?)"); err != nil { t.Fatalf("prepare failed: %s", err) } stmt = txn.Stmt(stmt) _, err = stmt.Exec(999, "happening") if err != nil { t.Fatalf("exec failed: %s", err) } if err = txn.Commit(); err != nil { t.Fatalf("Commit failed: %s", err) } assertStored(t, db, "SELECT name FROM foo", []string{"happening"}) } func TestRollback(t *testing.T) { t.Skip() // txn support disabled db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") var txn *sql.Tx var stmt *sql.Stmt var err error if txn, err = db.Begin(); err != nil { t.Fatalf("begin failed: %s", err) } if stmt, err = db.Prepare("insert into foo values(?, ?)"); err != nil { t.Fatalf("prepare failed: %s", err) } stmt = txn.Stmt(stmt) _, err = stmt.Exec(666, "not happening") if err != nil { t.Fatalf("exec failed: %s", err) } if err = txn.Rollback(); err != nil { t.Fatalf("rollback failed: %s", err) } assertStored(t, db, "SELECT name FROM foo", []string{}) } func TestStarSelectSingle(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") wantID := 11 wantName := "Eleven" _, err := db.Exec("INSERT INTO foo VALUES(?,?)", wantID, wantName) if err != nil { t.Fatalf("Insert failed: %s", err) } stmt, err := db.Prepare("select * from foo") if err != nil { t.Fatal(err) } var id int var name string if err = stmt.QueryRow().Scan(&id, &name); err != nil { t.Fatalf("Failed to query row: %s", err) } stmt.Close() if id != wantID { t.Errorf("ID: got %d want %d", id, wantID) } if name != wantName { t.Errorf("Name got %s want %s", name, wantName) } } func TestStarSelectMulti(t *testing.T) { db := newDB(t, "create table foo(id INTEGER PRIMARY KEY, name string)") wantIDs := []int{11, 12, 13} wantNames := []string{"Eleven", "Mike", "Dustin"} for i := range wantIDs { _, err := db.Exec("INSERT INTO foo VALUES(?,?)", wantIDs[i], wantNames[i]) if err != nil { t.Fatalf("Insert failed: %s", err) } } rows, err := db.Query("select * from foo") if err != nil { t.Fatal(err) } defer rows.Close() i := 0 for rows.Next() { var id int var name string err := rows.Scan(&id, &name) if err != nil { t.Fatalf("Scan failed: %s", err) } if id != wantIDs[i] { t.Errorf("Row %d: got ID %d, want %d", i, id, wantIDs[i]) } if name != wantNames[i] { t.Errorf("Row %d: got name %s, want %s", i, name, wantNames[i]) } i++ } if len(wantIDs) != i { t.Errorf("Mismatched number of returned rows: %d != %d", len(wantIDs), i) } }
-
-
vendor/go-sqlite3-js/stmt.go (deleted)
-
@@ -1,224 +0,0 @@package sqlite3_js //nolint:golint import ( "context" "database/sql/driver" "fmt" "sync" "syscall/js" ) // SqliteJsStmt implements driver.Stmt. type SqliteJsStmt struct { c *SqliteJsConn js js.Value // sql.js Statement: https://sql-js.github.io/sql.js/documentation/class/Statement.html mu sync.Mutex closed bool cls bool // wild guess: connection level statement? hasNext bool } type namedValue struct { Name string Ordinal int Value driver.Value } // Exec executes a prepared statement with the given arguments and returns a // Result summarizing the effect of the statement. func (s *SqliteJsStmt) Exec(args []driver.Value) (driver.Result, error) { list := make([]namedValue, len(args)) for i, v := range args { list[i] = namedValue{ Ordinal: i + 1, Value: v, } } return s.exec(context.Background(), list) } // ExecContext executes a query that doesn't return rows, such // as an INSERT or UPDATE. // // ExecContext must honor the context timeout and return when it is canceled. func (s *SqliteJsStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) { list := make([]namedValue, len(args)) for i, nv := range args { list[i] = namedValue(nv) } return s.exec(ctx, list) } // exec executes a query that doesn't return rows. Attempts to honor context timeout. func (s *SqliteJsStmt) exec(ctx context.Context, args []namedValue) (driver.Result, error) { if ctx.Done() == nil { return s.execSync(args) } type result struct { r driver.Result err error } resultCh := make(chan result) go func() { defer func() { if perr := recover(); perr != nil { fmt.Printf("SqliteJsStmt.exec panicked! nargs=%d err=%s", len(args), perr) resultCh <- result{nil, fmt.Errorf("SqliteJsStmt.exec panicked! nargs=%d err=%s", len(args), perr)} } }() r, err := s.execSync(args) resultCh <- result{r, err} }() select { case rv := <-resultCh: return rv.r, rv.err case <-ctx.Done(): select { case <-resultCh: // no need to interrupt default: // FIXME: find a way to actually interrupt the connection // this is still racy and can be no-op if executed between sqlite3_* calls in execSync. // C.sqlite3_interrupt(s.c.db) <-resultCh // ensure goroutine completed } return nil, ctx.Err() } } func (s *SqliteJsStmt) execSync(args []namedValue) (driver.Result, error) { // We're going to issue a bunch of JS calls, some of which (last rowid) // are NOT statement-level scoped, but connection-level scoped, so we cannot just // lock the statement mutex we have already, otherwise multiple goroutines may // exec in this function, causing the last insert rowid to be wrong. s.c.mu.Lock() defer s.c.mu.Unlock() jsArgs := make([]interface{}, len(args)) for i, v := range args { if bval, ok := v.Value.([]byte); ok { dst := js.Global().Get("Uint8Array").New(len(bval)) js.CopyBytesToJS(dst, bval) jsArgs[i] = dst } else { jsArgs[i] = js.ValueOf(v.Value) } } result, err := jsTryCatch(func() js.Value { return s.js.Call("run", jsArgs) }) if err != nil { return nil, fmt.Errorf("execSync sql.js: %s", err) } // TODO: Kinda sucks each exec is paired with 2 extra calls but we have to do it ASAP else we risk // getting out of sync with subsequent inserts. rowsModified := s.c.JsDb.Call("getRowsModified") rowidRes, err := jsTryCatch(func() js.Value { rows := s.c.JsDb.Call("exec", "SELECT last_insert_rowid()") if rows.Length() != 1 { // query result // this gets recover()d and turns into an error panic(fmt.Sprintf("last_insert_rowid: expected 1 row to be returned, got %d", rows.Length())) } // 'rows' is of the form: [{columns: ['id'], values:[[1],[2],[3]]}] return rows.Index(0).Get("values").Index(0).Index(0) }) if err != nil { return nil, fmt.Errorf("execSync: error getting rowid: %s", err) } return &SqliteJsResult{ js: result, changes: int64(rowsModified.Int()), id: int64(rowidRes.Int()), }, nil } // Query executes a query that may return rows, such as a // SELECT. // // Deprecated: Drivers should implement StmtQueryContext instead (or additionally). func (s *SqliteJsStmt) Query(args []driver.Value) (driver.Rows, error) { list := make([]namedValue, len(args)) for i, v := range args { list[i] = namedValue{ Ordinal: i + 1, Value: v, } } return s.query(context.Background(), list) } // QueryContext executes a query that may return rows, such as a // SELECT. // // QueryContext must honor the context timeout and return when it is canceled. func (s *SqliteJsStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) { list := make([]namedValue, len(args)) for i, nv := range args { list[i] = namedValue(nv) } return s.query(ctx, list) } func (s *SqliteJsStmt) query(ctx context.Context, args []namedValue) (driver.Rows, error) { jsArgs := make([]interface{}, len(args)) for i, v := range args { if bval, ok := v.Value.([]byte); ok { dst := js.Global().Get("Uint8Array").New(len(bval)) js.CopyBytesToJS(dst, bval) jsArgs[i] = dst } else { jsArgs[i] = js.ValueOf(v.Value) } } jsOk := s.js.Call("bind", jsArgs) if !jsOk.Bool() { return nil, fmt.Errorf("failed to bind query") } res := s.js.Call("step") s.hasNext = res.Bool() return &SqliteJsRows{ s: s, cls: s.cls, // FIXME: we never set s.cls, as we haven't implemented conn.Query(), which would set it ctx: ctx, }, nil } func (s *SqliteJsStmt) Next() *js.Value { if !s.hasNext { return nil } row := s.js.Call("get") jsHasNext := s.js.Call("step") s.hasNext = jsHasNext.Bool() return &row } // NumInput returns the number of placeholder parameters. // // If NumInput returns >= 0, the sql package will sanity check // argument counts from callers and return errors to the caller // before the statement's Exec or Query methods are called. // // NumInput may also return -1, if the driver doesn't know // its number of placeholders. In that case, the sql package // will not sanity check Exec or Query argument counts. func (s *SqliteJsStmt) NumInput() int { return -1 } // Close closes the statement. func (s *SqliteJsStmt) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.closed { return nil } s.closed = true res := s.js.Call("free") if !res.Bool() { return fmt.Errorf("couldn't close stmt") } return nil }
-
-
vendor/go-sqlite3-js/wasm_exec.js (deleted)
-
@@ -1,538 +0,0 @@// Copyright 2018 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. const initSqlJs = require('sql.js'); (() => { // Load sql.js initSqlJs().then((SQL) => { global._go_sqlite = SQL; }) // Map multiple JavaScript environments to a single common API, // preferring web standards over Node.js API. // // Environments considered: // - Browsers // - Node.js // - Electron // - Parcel if (typeof global !== "undefined") { // global already exists } else if (typeof window !== "undefined") { window.global = window; } else if (typeof self !== "undefined") { self.global = self; } else { throw new Error("cannot export Go (neither global, window nor self is defined)"); } if (!global.require && typeof require !== "undefined") { global.require = require; } if (!global.fs && global.require) { global.fs = require("fs"); } if (!global.fs) { let outputBuf = ""; global.fs = { constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused writeSync(fd, buf) { outputBuf += decoder.decode(buf); const nl = outputBuf.lastIndexOf("\n"); if (nl != -1) { console.log(outputBuf.substr(0, nl)); outputBuf = outputBuf.substr(nl + 1); } return buf.length; }, write(fd, buf, offset, length, position, callback) { if (offset !== 0 || length !== buf.length || position !== null) { throw new Error("not implemented"); } const n = this.writeSync(fd, buf); callback(null, n); }, open(path, flags, mode, callback) { const err = new Error("not implemented"); err.code = "ENOSYS"; callback(err); }, read(fd, buffer, offset, length, position, callback) { const err = new Error("not implemented"); err.code = "ENOSYS"; callback(err); }, fsync(fd, callback) { callback(null); }, }; } if (!global.crypto) { const nodeCrypto = require("crypto"); global.crypto = { getRandomValues(b) { nodeCrypto.randomFillSync(b); }, }; } if (!global.performance) { global.performance = { now() { const [sec, nsec] = process.hrtime(); return sec * 1000 + nsec / 1000000; }, }; } if (!global.TextEncoder) { global.TextEncoder = require("util").TextEncoder; } if (!global.TextDecoder) { global.TextDecoder = require("util").TextDecoder; } // End of polyfills for common API. const encoder = new TextEncoder("utf-8"); const decoder = new TextDecoder("utf-8"); global.Go = class { constructor() { this.argv = ["js"]; this.env = {}; this.exit = (code) => { if (code !== 0) { console.warn("exit code:", code); } }; this._exitPromise = new Promise((resolve) => { this._resolveExitPromise = resolve; }); this._pendingEvent = null; this._scheduledTimeouts = new Map(); this._nextCallbackTimeoutID = 1; const mem = () => { // The buffer may change when requesting more memory. return new DataView(this._inst.exports.mem.buffer); } const setInt64 = (addr, v) => { mem().setUint32(addr + 0, v, true); mem().setUint32(addr + 4, Math.floor(v / 4294967296), true); } const getInt64 = (addr) => { const low = mem().getUint32(addr + 0, true); const high = mem().getInt32(addr + 4, true); return low + high * 4294967296; } const loadValue = (addr) => { const f = mem().getFloat64(addr, true); if (f === 0) { return undefined; } if (!isNaN(f)) { return f; } const id = mem().getUint32(addr, true); return this._values[id]; } const storeValue = (addr, v) => { const nanHead = 0x7FF80000; if (typeof v === "number") { if (isNaN(v)) { mem().setUint32(addr + 4, nanHead, true); mem().setUint32(addr, 0, true); return; } if (v === 0) { mem().setUint32(addr + 4, nanHead, true); mem().setUint32(addr, 1, true); return; } mem().setFloat64(addr, v, true); return; } switch (v) { case undefined: mem().setFloat64(addr, 0, true); return; case null: mem().setUint32(addr + 4, nanHead, true); mem().setUint32(addr, 2, true); return; case true: mem().setUint32(addr + 4, nanHead, true); mem().setUint32(addr, 3, true); return; case false: mem().setUint32(addr + 4, nanHead, true); mem().setUint32(addr, 4, true); return; } let ref = this._refs.get(v); if (ref === undefined) { ref = this._values.length; this._values.push(v); this._refs.set(v, ref); } let typeFlag = 0; switch (typeof v) { case "string": typeFlag = 1; break; case "symbol": typeFlag = 2; break; case "function": typeFlag = 3; break; } mem().setUint32(addr + 4, nanHead | typeFlag, true); mem().setUint32(addr, ref, true); } const loadSlice = (addr) => { const array = getInt64(addr + 0); const len = getInt64(addr + 8); return new Uint8Array(this._inst.exports.mem.buffer, array, len); } const loadSliceOfValues = (addr) => { const array = getInt64(addr + 0); const len = getInt64(addr + 8); const a = new Array(len); for (let i = 0; i < len; i++) { a[i] = loadValue(array + i * 8); } return a; } const loadString = (addr) => { const saddr = getInt64(addr + 0); const len = getInt64(addr + 8); return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); } const timeOrigin = Date.now() - performance.now(); this.importObject = { go: { // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). // This changes the SP, thus we have to update the SP used by the imported function. // func wasmExit(code int32) "runtime.wasmExit": (sp) => { const code = mem().getInt32(sp + 8, true); this.exited = true; delete this._inst; delete this._values; delete this._refs; this.exit(code); }, // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) "runtime.wasmWrite": (sp) => { const fd = getInt64(sp + 8); const p = getInt64(sp + 16); const n = mem().getInt32(sp + 24, true); fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); }, // func nanotime() int64 "runtime.nanotime": (sp) => { setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); }, // func walltime() (sec int64, nsec int32) "runtime.walltime": (sp) => { const msec = (new Date).getTime(); setInt64(sp + 8, msec / 1000); mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); }, // func scheduleTimeoutEvent(delay int64) int32 "runtime.scheduleTimeoutEvent": (sp) => { const id = this._nextCallbackTimeoutID; this._nextCallbackTimeoutID++; this._scheduledTimeouts.set(id, setTimeout( () => { this._resume(); while (this._scheduledTimeouts.has(id)) { // for some reason Go failed to register the timeout event, log and try again // (temporary workaround for https://github.com/golang/go/issues/28975) console.warn("scheduleTimeoutEvent: missed timeout event"); this._resume(); } }, getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early )); mem().setInt32(sp + 16, id, true); }, // func clearTimeoutEvent(id int32) "runtime.clearTimeoutEvent": (sp) => { const id = mem().getInt32(sp + 8, true); clearTimeout(this._scheduledTimeouts.get(id)); this._scheduledTimeouts.delete(id); }, // func getRandomData(r []byte) "runtime.getRandomData": (sp) => { crypto.getRandomValues(loadSlice(sp + 8)); }, // func stringVal(value string) ref "syscall/js.stringVal": (sp) => { storeValue(sp + 24, loadString(sp + 8)); }, // func valueGet(v ref, p string) ref "syscall/js.valueGet": (sp) => { const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); sp = this._inst.exports.getsp(); // see comment above storeValue(sp + 32, result); }, // func valueSet(v ref, p string, x ref) "syscall/js.valueSet": (sp) => { Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); }, // func valueIndex(v ref, i int) ref "syscall/js.valueIndex": (sp) => { storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); }, // valueSetIndex(v ref, i int, x ref) "syscall/js.valueSetIndex": (sp) => { Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); }, // func valueCall(v ref, m string, args []ref) (ref, bool) "syscall/js.valueCall": (sp) => { try { const v = loadValue(sp + 8); const m = Reflect.get(v, loadString(sp + 16)); const args = loadSliceOfValues(sp + 32); const result = Reflect.apply(m, v, args); sp = this._inst.exports.getsp(); // see comment above storeValue(sp + 56, result); mem().setUint8(sp + 64, 1); } catch (err) { storeValue(sp + 56, err); mem().setUint8(sp + 64, 0); } }, // func valueInvoke(v ref, args []ref) (ref, bool) "syscall/js.valueInvoke": (sp) => { try { const v = loadValue(sp + 8); const args = loadSliceOfValues(sp + 16); const result = Reflect.apply(v, undefined, args); sp = this._inst.exports.getsp(); // see comment above storeValue(sp + 40, result); mem().setUint8(sp + 48, 1); } catch (err) { storeValue(sp + 40, err); mem().setUint8(sp + 48, 0); } }, // func valueNew(v ref, args []ref) (ref, bool) "syscall/js.valueNew": (sp) => { try { const v = loadValue(sp + 8); const args = loadSliceOfValues(sp + 16); const result = Reflect.construct(v, args); sp = this._inst.exports.getsp(); // see comment above storeValue(sp + 40, result); mem().setUint8(sp + 48, 1); } catch (err) { storeValue(sp + 40, err); mem().setUint8(sp + 48, 0); } }, // func valueLength(v ref) int "syscall/js.valueLength": (sp) => { setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); }, // valuePrepareString(v ref) (ref, int) "syscall/js.valuePrepareString": (sp) => { const str = encoder.encode(String(loadValue(sp + 8))); storeValue(sp + 16, str); setInt64(sp + 24, str.length); }, // valueLoadString(v ref, b []byte) "syscall/js.valueLoadString": (sp) => { const str = loadValue(sp + 8); loadSlice(sp + 16).set(str); }, // func valueInstanceOf(v ref, t ref) bool "syscall/js.valueInstanceOf": (sp) => { mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); }, // func copyBytesToGo(dst []byte, src ref) (int, bool) "syscall/js.copyBytesToGo": (sp) => { const dst = loadSlice(sp + 8); const src = loadValue(sp + 32); if (!(src instanceof Uint8Array)) { mem().setUint8(sp + 48, 0); return; } const toCopy = src.subarray(0, dst.length); dst.set(toCopy); setInt64(sp + 40, toCopy.length); mem().setUint8(sp + 48, 1); }, // func copyBytesToJS(dst ref, src []byte) (int, bool) "syscall/js.copyBytesToJS": (sp) => { const dst = loadValue(sp + 8); const src = loadSlice(sp + 16); if (!(dst instanceof Uint8Array)) { mem().setUint8(sp + 48, 0); return; } const toCopy = src.subarray(0, dst.length); dst.set(toCopy); setInt64(sp + 40, toCopy.length); mem().setUint8(sp + 48, 1); }, "debug": (value) => { console.log(value); }, } }; } async run(instance) { this._inst = instance; this._values = [ // TODO: garbage collection NaN, 0, null, true, false, global, this, ]; this._refs = new Map(); this.exited = false; const mem = new DataView(this._inst.exports.mem.buffer) // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. let offset = 4096; const strPtr = (str) => { const ptr = offset; const bytes = encoder.encode(str + "\0"); new Uint8Array(mem.buffer, offset, bytes.length).set(bytes); offset += bytes.length; if (offset % 8 !== 0) { offset += 8 - (offset % 8); } return ptr; }; const argc = this.argv.length; const argvPtrs = []; this.argv.forEach((arg) => { argvPtrs.push(strPtr(arg)); }); const keys = Object.keys(this.env).sort(); argvPtrs.push(keys.length); keys.forEach((key) => { argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); }); const argv = offset; argvPtrs.forEach((ptr) => { mem.setUint32(offset, ptr, true); mem.setUint32(offset + 4, 0, true); offset += 8; }); this._inst.exports.run(argc, argv); if (this.exited) { this._resolveExitPromise(); } await this._exitPromise; } _resume() { if (this.exited) { throw new Error("Go program has already exited"); } this._inst.exports.resume(); if (this.exited) { this._resolveExitPromise(); } } _makeFuncWrapper(id) { const go = this; return function () { const event = { id: id, this: this, args: arguments }; go._pendingEvent = event; go._resume(); return event.result; }; } } if ( global.require && global.require.main === module && global.process && global.process.versions && !global.process.versions.electron ) { if (process.argv.length < 3) { console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); process.exit(1); } const go = new Go(); go.argv = process.argv.slice(2); go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env); go.exit = process.exit; WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { process.on("exit", (code) => { // Node.js exits if no event handler is pending if (code === 0 && !go.exited) { // deadlock, make Go print error and stack traces go._pendingEvent = { id: 0 }; go._resume(); } }); return go.run(result.instance); }).catch((err) => { console.error(err); process.exit(1); }); } })();
-