From bde8398d46496ae2595d33d09ed6b6ba056de26e Mon Sep 17 00:00:00 2001 From: Soma Nakamura Date: Sat, 5 Jul 2025 11:50:50 +0900 Subject: [PATCH] initial --- .env.example | 21 + .envrc | 29 + .gitignore | 81 +++ CLAUDE.md | 61 +++ Cargo.toml | 67 +++ DESIGN.md | 345 ++++++++++++ flake.lock | 82 +++ flake.nix | 277 ++++++++++ .../20240704000001_create_items_table.sql | 27 + .../20240704000002_create_loans_table.sql | 18 + ...250705000001_create_cable_colors_table.sql | 24 + src/config.rs | 152 ++++++ src/db/mod.rs | 85 +++ src/error.rs | 101 ++++ src/handlers/cable_colors.rs | 78 +++ src/handlers/images.rs | 87 +++ src/handlers/items.rs | 130 +++++ src/handlers/loans.rs | 79 +++ src/handlers/mod.rs | 15 + src/main.rs | 111 ++++ src/models/cable_color.rs | 46 ++ src/models/item.rs | 107 ++++ src/models/loan.rs | 72 +++ src/models/mod.rs | 7 + src/services/cable_color_service.rs | 170 ++++++ src/services/item_service.rs | 496 ++++++++++++++++++ src/services/loan_service.rs | 313 +++++++++++ src/services/mod.rs | 9 + src/services/storage.rs | 178 +++++++ test_item.json | 1 + 30 files changed, 3269 insertions(+) create mode 100644 .env.example create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.toml create mode 100644 DESIGN.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 migrations/20240704000001_create_items_table.sql create mode 100644 migrations/20240704000002_create_loans_table.sql create mode 100644 migrations/20250705000001_create_cable_colors_table.sql create mode 100644 src/config.rs create mode 100644 src/db/mod.rs create mode 100644 src/error.rs create mode 100644 src/handlers/cable_colors.rs create mode 100644 src/handlers/images.rs create mode 100644 src/handlers/items.rs create mode 100644 src/handlers/loans.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/main.rs create mode 100644 src/models/cable_color.rs create mode 100644 src/models/item.rs create mode 100644 src/models/loan.rs create mode 100644 src/models/mod.rs create mode 100644 src/services/cable_color_service.rs create mode 100644 src/services/item_service.rs create mode 100644 src/services/loan_service.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/storage.rs create mode 100644 test_item.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c633332 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Database +DATABASE_URL=sqlite://hyperdashi.db +# For production: DATABASE_URL=postgres://user:password@localhost/hyperdashi + +# Server +SERVER_HOST=127.0.0.1 +SERVER_PORT=8080 + +# Storage +STORAGE_TYPE=local +LOCAL_STORAGE_PATH=./uploads + +# For S3 storage (production) +# STORAGE_TYPE=s3 +# AWS_ACCESS_KEY_ID=your_access_key +# AWS_SECRET_ACCESS_KEY=your_secret_key +# AWS_REGION=ap-northeast-1 +# S3_BUCKET_NAME=hyperdashi-images + +# Logging +RUST_LOG=hyperdashi_server=debug,tower_http=debug,sqlx=warn \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..a761330 --- /dev/null +++ b/.envrc @@ -0,0 +1,29 @@ +# Automatically load the nix environment +use flake + +# Load environment variables from .env file if it exists +dotenv_if_exists + +# Set development environment variables +export DATABASE_URL="sqlite://hyperdashi.db" +export RUST_LOG="debug" +export RUST_BACKTRACE="1" + +# Server configuration +export SERVER_HOST="127.0.0.1" +export SERVER_PORT="8081" + +# Storage configuration +export STORAGE_TYPE="local" +export STORAGE_MAX_FILE_SIZE_MB="10" +export LOCAL_STORAGE_PATH="./uploads" + +# Rust development settings +export CARGO_TARGET_DIR="target" +export RUSTFLAGS="--deny warnings" + +# Create necessary directories +mkdir -p uploads + +echo "Development environment loaded!" +echo "Run 'nix run .#dev' to start the development server" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb2b048 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Rust +/target/ +**/*.rs.bk +Cargo.lock + +# Database files +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Environment files +.env +.env.local +.env.production +.env.*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Uploads and temporary files +/uploads/ +tmp/ +temp/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Nix +result +result-* +.direnv/ + +# Node modules (for frontend) +node_modules/ +npm-debug.log +yarn-error.log +.pnpm-debug.log + +# Build outputs +dist/ +build/ +*.tgz +*.tar.gz + +# Coverage reports +coverage/ +*.lcov +tarpaulin-report.html + +# Backup files +*_backup_* +*.bak +*.backup + +# Test files +test.db +*test*.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad1c9cc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +これは、情報メディアシステム局の物品管理システム「dashi」の発展版・汎用版であるところの「hyperdashi」のバックエンドである。 + +dashiはhttps://dashi.sohosai.comから使うことができる。 +dashiではグラフデータベースと検索エンジンを利用していたが、運用の煩雑さと見合わないことから、RDBに置き換えることを目標としている。 + +以下にhyperdashiの要件を述べる。 + +物品情報の登録 + 機材の物品情報を登録する。 + 物品名 + ラベルID + これがQRコード/バーコードによって物品に貼り付けられ、このラベルIDを参照することとなる + Alphanumeric(使わない文字の制限あり、IとOは1と0に見間違えるので使わない) + + 型番 + 備考 + 長さや古さ、傷があるなどを書く + 購入年度 + 購入金額 + 耐久年数 + 減価償却対象かどうか + 高い物品を買ったときに考える。過去に買ったものに関しては減価償却対象ではない + 接続名(端子の名前を書く) + 可変長配列らしい + ケーブル識別色のパターン + ケーブルに貼ってある色を端子側から順番に追加する。 + 白に注意!白は良く見えない + 収納場所 + 複数選べる。なぜなら、部屋AのラックXのコンテナαに収納されているような場合に「部屋A」「ラックX」「コンテナα」と3つ書ければ検索性が良いと言えるから。 + 貸出中か否か + QRかバーコードかラベル未貼付か + 登録日時 + これは自動で割当たる + 更新日時 + これも自動で割当たる + 廃棄/譲渡済み + 削除とは別。削除はミスった時用で、廃棄時はこのフラグを立てる + 画像 + URLの予定。登録時はファイルが直接上がってくるので、それをオブジェクトストレージにアップロードした結果のURLか、ローカルにフォールバックされた結果のバックエンドのURLが入ることになる。 + +部分的な更新も可能、このAPIも必要 +Excel風のUIから登録・編集を行う予定 + +貸出管理のテーブル + 貸出物品のID + 誰に貸出中か + 学籍番号と名前 + どこに貸出中か + String、団体名などが入る予定 + 貸出日時 + 返却日時 + 備考 + +貸出し情報も登録・更新(返却時)するためのAPIが必要 + +画像はS3系のオブジェクトストレージにアップロードすることを予定しているが、envの設定によってローカルにフォールバックできると嬉しい。 +RDBも然り。プロダクションではPostgreSQLの予定だが、ローカルのsqliteにフォールバックできると嬉しい(開発時に)。 + +サーバはRustで記述し、AxumとSQLxで実装する。 +クライアントとの間ではREST APIを使ってやり取りを行う。 + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7d0f65d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "hyperdashi-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Web framework +axum = { version = "0.7", features = ["multipart"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "trace", "fs"] } + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Database +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "sqlite", "json", "chrono", "rust_decimal", "migrate"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Configuration +config = "0.14" + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Date/Time +chrono = { version = "0.4", features = ["serde"] } + +# Decimal for money +rust_decimal = { version = "1.35", features = ["serde-float"] } + +# HTTP client (for S3) +reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false } + +# AWS SDK (for S3) +aws-config = "1.1" +aws-sdk-s3 = "1.14" + +# File uploads +multer = "3.0" + +# Validation +validator = { version = "0.18", features = ["derive"] } + +# Regular expressions +regex = "1.10" +lazy_static = "1.4" +once_cell = "1.19" + +# Environment variables +dotenvy = "0.15" + +# UUID for unique identifiers +uuid = { version = "1.7", features = ["v4", "serde"] } + +# Async trait +async-trait = "0.1" + +[dev-dependencies] +tokio-test = "0.4" diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..576a5cc --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,345 @@ +# HyperDashi バックエンド詳細設計書 + +## 1. システム概要 + +HyperDashiは、情報メディアシステム局の物品管理システム「dashi」の発展版・汎用版である。グラフデータベースと検索エンジンを使用していた従来システムから、運用の簡素化を目的としてRDBベースのシステムに移行する。 + +### 1.1 技術スタック +- **言語**: Rust +- **Webフレームワーク**: Axum +- **データベース**: PostgreSQL (本番環境) / SQLite (開発環境) +- **ORM**: SQLx +- **ストレージ**: S3互換オブジェクトストレージ (本番環境) / ローカルファイルシステム (開発環境) +- **API形式**: REST API + +## 2. データベース設計 + +### 2.1 物品情報テーブル (items) + +```sql +CREATE TABLE items ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + label_id VARCHAR(50) UNIQUE NOT NULL, + model_number VARCHAR(255), + remarks TEXT, + purchase_year INTEGER, + purchase_amount DECIMAL(12, 2), + useful_life INTEGER, + is_depreciable BOOLEAN DEFAULT FALSE, + connection_names TEXT[], -- PostgreSQL配列型、SQLiteではJSON + cable_color_pattern TEXT[], -- PostgreSQL配列型、SQLiteではJSON + storage_locations TEXT[], -- PostgreSQL配列型、SQLiteではJSON + is_on_loan BOOLEAN DEFAULT FALSE, + label_type VARCHAR(20) CHECK (label_type IN ('qr', 'barcode', 'none')), + is_disposed BOOLEAN DEFAULT FALSE, + image_url TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- インデックス +CREATE INDEX idx_items_label_id ON items(label_id); +CREATE INDEX idx_items_name ON items(name); +CREATE INDEX idx_items_is_on_loan ON items(is_on_loan); +CREATE INDEX idx_items_is_disposed ON items(is_disposed); +``` + +### 2.2 貸出管理テーブル (loans) + +```sql +CREATE TABLE loans ( + id SERIAL PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES items(id), + student_number VARCHAR(20) NOT NULL, + student_name VARCHAR(100) NOT NULL, + organization VARCHAR(255), + loan_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + return_date TIMESTAMP WITH TIME ZONE, + remarks TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- インデックス +CREATE INDEX idx_loans_item_id ON loans(item_id); +CREATE INDEX idx_loans_student_number ON loans(student_number); +CREATE INDEX idx_loans_return_date ON loans(return_date); +``` + +### 2.3 画像管理テーブル (images) - オプション + +```sql +CREATE TABLE images ( + id SERIAL PRIMARY KEY, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + storage_type VARCHAR(20) CHECK (storage_type IN ('s3', 'local')), + storage_path TEXT NOT NULL, + size_bytes BIGINT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +``` + +## 3. API設計 + +### 3.1 物品管理API + +#### 3.1.1 物品一覧取得 +- **エンドポイント**: `GET /api/v1/items` +- **クエリパラメータ**: + - `page` (optional): ページ番号 + - `per_page` (optional): 1ページあたりの件数 + - `search` (optional): 検索キーワード + - `is_on_loan` (optional): 貸出中フィルタ + - `is_disposed` (optional): 廃棄済みフィルタ +- **レスポンス**: +```json +{ + "items": [ + { + "id": 1, + "name": "HDMIケーブル 3m", + "label_id": "HYP-A001", + "model_number": "HDMI-3M-V2", + "remarks": "端子部分に少し傷あり", + "purchase_year": 2023, + "purchase_amount": 1500.00, + "useful_life": 5, + "is_depreciable": false, + "connection_names": ["HDMI Type-A", "HDMI Type-A"], + "cable_color_pattern": ["赤", "青", "赤"], + "storage_locations": ["部屋A", "ラックX", "コンテナα"], + "is_on_loan": false, + "label_type": "qr", + "is_disposed": false, + "image_url": "https://storage.example.com/images/hdmi-cable.jpg", + "created_at": "2023-04-01T10:00:00Z", + "updated_at": "2023-04-01T10:00:00Z" + } + ], + "total": 150, + "page": 1, + "per_page": 20 +} +``` + +#### 3.1.2 物品詳細取得 +- **エンドポイント**: `GET /api/v1/items/{id}` +- **レスポンス**: 単一の物品オブジェクト + +#### 3.1.3 物品登録 +- **エンドポイント**: `POST /api/v1/items` +- **リクエストボディ**: +```json +{ + "name": "HDMIケーブル 3m", + "label_id": "HYP-A001", + "model_number": "HDMI-3M-V2", + "remarks": "端子部分に少し傷あり", + "purchase_year": 2023, + "purchase_amount": 1500.00, + "useful_life": 5, + "is_depreciable": false, + "connection_names": ["HDMI Type-A", "HDMI Type-A"], + "cable_color_pattern": ["赤", "青", "赤"], + "storage_locations": ["部屋A", "ラックX", "コンテナα"], + "label_type": "qr" +} +``` + +#### 3.1.4 物品更新 +- **エンドポイント**: `PUT /api/v1/items/{id}` +- **リクエストボディ**: 物品登録と同じ(全項目更新) + +#### 3.1.5 物品部分更新 +- **エンドポイント**: `PATCH /api/v1/items/{id}` +- **リクエストボディ**: 更新したいフィールドのみ + +#### 3.1.6 物品削除 +- **エンドポイント**: `DELETE /api/v1/items/{id}` +- **説明**: 論理削除ではなく物理削除(ミス入力の修正用) + +#### 3.1.7 物品廃棄/譲渡 +- **エンドポイント**: `POST /api/v1/items/{id}/dispose` +- **説明**: is_disposedフラグを立てる + +#### 3.1.8 ラベルIDによる物品検索 +- **エンドポイント**: `GET /api/v1/items/by-label/{label_id}` +- **説明**: QRコード/バーコード読み取り時の検索用 + +### 3.2 貸出管理API + +#### 3.2.1 貸出登録 +- **エンドポイント**: `POST /api/v1/loans` +- **リクエストボディ**: +```json +{ + "item_id": 1, + "student_number": "21001234", + "student_name": "山田太郎", + "organization": "第74回総合祭実行委員会", + "remarks": "イベント用機材" +} +``` + +#### 3.2.2 返却処理 +- **エンドポイント**: `POST /api/v1/loans/{id}/return` +- **リクエストボディ**: +```json +{ + "return_date": "2023-04-10T15:00:00Z", + "remarks": "問題なく返却" +} +``` + +#### 3.2.3 貸出履歴取得 +- **エンドポイント**: `GET /api/v1/loans` +- **クエリパラメータ**: + - `item_id` (optional): 物品ID + - `student_number` (optional): 学籍番号 + - `active_only` (optional): 未返却のみ + +#### 3.2.4 貸出詳細取得 +- **エンドポイント**: `GET /api/v1/loans/{id}` + +### 3.3 画像アップロードAPI + +#### 3.3.1 画像アップロード +- **エンドポイント**: `POST /api/v1/images/upload` +- **Content-Type**: `multipart/form-data` +- **レスポンス**: +```json +{ + "url": "https://storage.example.com/images/abc123.jpg" +} +``` + +### 3.4 一括操作API + +#### 3.4.1 物品一括登録 +- **エンドポイント**: `POST /api/v1/items/bulk` +- **リクエストボディ**: 物品オブジェクトの配列 + +#### 3.4.2 物品一括更新 +- **エンドポイント**: `PUT /api/v1/items/bulk` +- **リクエストボディ**: 更新対象の物品オブジェクトの配列 + +## 4. アプリケーション構造 + +### 4.1 ディレクトリ構造 +``` +hyperdashi-server/ +├── src/ +│ ├── main.rs # エントリーポイント +│ ├── config.rs # 設定管理 +│ ├── db/ +│ │ ├── mod.rs # データベース接続管理 +│ │ └── migrations/ # SQLマイグレーション +│ ├── models/ +│ │ ├── mod.rs +│ │ ├── item.rs # 物品モデル +│ │ └── loan.rs # 貸出モデル +│ ├── handlers/ +│ │ ├── mod.rs +│ │ ├── items.rs # 物品ハンドラー +│ │ ├── loans.rs # 貸出ハンドラー +│ │ └── images.rs # 画像ハンドラー +│ ├── services/ +│ │ ├── mod.rs +│ │ ├── item_service.rs # 物品ビジネスロジック +│ │ ├── loan_service.rs # 貸出ビジネスロジック +│ │ └── storage.rs # ストレージ抽象化 +│ ├── utils/ +│ │ ├── mod.rs +│ │ ├── validation.rs # バリデーション +│ │ └── label.rs # ラベルID生成 +│ └── error.rs # エラー型定義 +├── Cargo.toml +├── .env.example +└── README.md +``` + +### 4.2 主要コンポーネント + +#### 4.2.1 設定管理 (config.rs) +```rust +#[derive(Debug, Deserialize)] +pub struct Config { + pub database_url: String, + pub server_host: String, + pub server_port: u16, + pub storage_type: StorageType, + pub s3_config: Option, + pub local_storage_path: Option, +} + +#[derive(Debug, Deserialize)] +pub enum StorageType { + S3, + Local, +} +``` + +#### 4.2.2 ストレージ抽象化 (storage.rs) +```rust +#[async_trait] +pub trait Storage: Send + Sync { + async fn upload(&self, data: Vec, filename: &str) -> Result; + async fn delete(&self, url: &str) -> Result<()>; +} + +pub struct S3Storage { /* ... */ } +pub struct LocalStorage { /* ... */ } +``` + +## 5. セキュリティ考慮事項 + +### 5.1 認証・認可 +- 初期版では認証なし(内部システムのため) +- 将来的にはJWT等による認証を実装予定 + +### 5.2 入力検証 +- ラベルIDの形式検証(英数字、I/O除外) +- SQLインジェクション対策(SQLx使用) +- ファイルアップロードのサイズ制限とMIMEタイプ検証 + +### 5.3 データ保護 +- 個人情報(学籍番号、氏名)の適切な管理 +- HTTPSによる通信の暗号化 + +## 6. パフォーマンス最適化 + +### 6.1 データベース +- 適切なインデックスの設定 +- N+1問題の回避 +- コネクションプーリング + +### 6.2 画像処理 +- 画像のリサイズとサムネイル生成 +- CDN利用による配信最適化(将来) + +### 6.3 キャッシング +- 頻繁にアクセスされる物品情報のキャッシング(将来) + +## 7. 運用・保守 + +### 7.1 ロギング +- 構造化ログの出力 +- エラートラッキング + +### 7.2 モニタリング +- ヘルスチェックエンドポイント +- メトリクス収集(将来) + +### 7.3 バックアップ +- データベースの定期バックアップ +- 画像データのバックアップ + +## 8. 今後の拡張予定 + +- 認証・認可機能 +- 物品の予約機能 +- 統計・レポート機能 +- モバイルアプリ対応API +- WebSocket による リアルタイム更新 \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..640bb45 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1751271578, + "narHash": "sha256-P/SQmKDu06x8yv7i0s8bvnnuJYkxVGBWLWHaU+tt4YY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1751596734, + "narHash": "sha256-1tQOwmn3jEUQjH0WDJyklC+hR7Bj+iqx6ChtRX2QiPA=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "e28ba067a9368286a8bc88b68dc2ca92181a09f0", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..93ccb64 --- /dev/null +++ b/flake.nix @@ -0,0 +1,277 @@ +{ + description = "HyperDashi Backend Server Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, rust-overlay }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + + # Rust toolchain specification + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rustfmt" "clippy" "rust-analyzer" ]; + }; + + # Node.js for frontend development + nodejs = pkgs.nodejs_20; + + # Database and development tools + developmentTools = with pkgs; [ + # Database tools + sqlite + sqlx-cli + + # Development utilities + git + curl + jq + + # Text editors and IDE support + vim + nano + + # Build tools + pkg-config + openssl + + # Optional: GUI tools if available + dbeaver-bin + ]; + + # Runtime dependencies + runtimeDeps = with pkgs; [ + # SSL/TLS support + openssl + + # SQLite runtime + sqlite + + # Network tools for testing + netcat-gnu + ]; + + # Development shell packages + shellPackages = [ + rustToolchain + nodejs + pkgs.yarn + pkgs.pnpm + ] ++ developmentTools ++ runtimeDeps; + + in + { + # Development shell + devShells.default = pkgs.mkShell { + buildInputs = shellPackages; + + # Environment variables + shellHook = '' + echo "🦀 HyperDashi Development Environment" + echo "==================================================" + echo "Rust version: $(rustc --version)" + echo "Node.js version: $(node --version)" + echo "SQLite version: $(sqlite3 --version)" + echo "==================================================" + + # Set environment variables + export DATABASE_URL="sqlite://hyperdashi.db" + export RUST_LOG="debug" + export RUST_BACKTRACE=1 + + # Server configuration + export SERVER_HOST="127.0.0.1" + export SERVER_PORT="8081" + + # Storage configuration + export STORAGE_TYPE="local" + export STORAGE_MAX_FILE_SIZE_MB="10" + export LOCAL_STORAGE_PATH="./uploads" + + # Create uploads directory if it doesn't exist + mkdir -p uploads + + echo "Environment variables set:" + echo " DATABASE_URL: $DATABASE_URL" + echo " SERVER_PORT: $SERVER_PORT" + echo " STORAGE_MAX_FILE_SIZE_MB: $STORAGE_MAX_FILE_SIZE_MB" + echo "" + echo "Available commands:" + echo " cargo build - Build the project" + echo " cargo run - Run the development server" + echo " cargo test - Run tests" + echo " sqlx migrate run - Run database migrations" + echo " nix run .#setup-db - Initial database setup" + echo " nix run .#dev - Start development server" + echo " nix run .#test - Run all tests" + echo "" + ''; + + # Additional environment variables for development + DATABASE_URL = "sqlite://hyperdashi.db"; + RUST_LOG = "debug"; + RUST_BACKTRACE = "1"; + + # PKG_CONFIG_PATH for OpenSSL + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + + # Library paths + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + pkgs.openssl + pkgs.sqlite + ]; + }; + + # Package outputs + packages = { + # Backend binary + hyperdashi-server = pkgs.rustPlatform.buildRustPackage { + pname = "hyperdashi-server"; + version = "0.1.0"; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + nativeBuildInputs = with pkgs; [ + pkg-config + rustToolchain + ]; + + buildInputs = with pkgs; [ + openssl + sqlite + ]; + + # Skip tests during build (can be run separately) + doCheck = false; + + meta = with pkgs.lib; { + description = "HyperDashi equipment management system backend"; + license = licenses.mit; + maintainers = [ ]; + }; + }; + + # Docker image + docker-image = pkgs.dockerTools.buildImage { + name = "hyperdashi-server"; + tag = "latest"; + + contents = [ + self.packages.${system}.hyperdashi-server + pkgs.sqlite + pkgs.openssl + ]; + + config = { + Cmd = [ "${self.packages.${system}.hyperdashi-server}/bin/hyperdashi-server" ]; + Env = [ + "DATABASE_URL=sqlite:///data/hyperdashi.db" + "SERVER_HOST=0.0.0.0" + "SERVER_PORT=8080" + "STORAGE_TYPE=local" + "LOCAL_STORAGE_PATH=/uploads" + ]; + ExposedPorts = { + "8080/tcp" = {}; + }; + Volumes = { + "/data" = {}; + "/uploads" = {}; + }; + }; + }; + + default = self.packages.${system}.hyperdashi-server; + }; + + # Formatter + formatter = pkgs.nixpkgs-fmt; + + # Apps for easy running + apps = { + # Run the server + hyperdashi-server = flake-utils.lib.mkApp { + drv = self.packages.${system}.hyperdashi-server; + }; + + # Development server with auto-reload + dev = flake-utils.lib.mkApp { + drv = pkgs.writeShellScriptBin "hyperdashi-dev" '' + export DATABASE_URL="sqlite://hyperdashi.db" + export RUST_LOG="debug" + + echo "Starting HyperDashi development server..." + echo "Server will be available at http://localhost:8081" + + # Run migrations first + sqlx migrate run + + # Start the server with cargo watch for auto-reload + if command -v cargo-watch >/dev/null 2>&1; then + cargo watch -x run + else + echo "cargo-watch not found, installing..." + cargo install cargo-watch + cargo watch -x run + fi + ''; + }; + + # Database setup + setup-db = flake-utils.lib.mkApp { + drv = pkgs.writeShellScriptBin "setup-db" '' + export DATABASE_URL="sqlite://hyperdashi.db" + + echo "Setting up HyperDashi database..." + + # Create database file if it doesn't exist + touch hyperdashi.db + + # Run migrations + sqlx migrate run + + echo "Database setup complete!" + echo "Database file: $(pwd)/hyperdashi.db" + ''; + }; + + # Run tests + test = flake-utils.lib.mkApp { + drv = pkgs.writeShellScriptBin "hyperdashi-test" '' + export DATABASE_URL="sqlite://test.db" + export RUST_LOG="info" + + echo "Running HyperDashi tests..." + + # Clean up any existing test database + rm -f test.db + + # Run tests + cargo test + + # Clean up test database + rm -f test.db + + echo "Tests completed!" + ''; + }; + + default = self.apps.${system}.hyperdashi-server; + }; + } + ); +} \ No newline at end of file diff --git a/migrations/20240704000001_create_items_table.sql b/migrations/20240704000001_create_items_table.sql new file mode 100644 index 0000000..dfc4296 --- /dev/null +++ b/migrations/20240704000001_create_items_table.sql @@ -0,0 +1,27 @@ +-- Create items table (compatible with both PostgreSQL and SQLite) +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + label_id TEXT UNIQUE NOT NULL, + model_number TEXT, + remarks TEXT, + purchase_year INTEGER, + purchase_amount REAL, + durability_years INTEGER, + is_depreciation_target BOOLEAN DEFAULT FALSE, + connection_names TEXT, -- JSON array for both DBs + cable_color_pattern TEXT, -- JSON array for both DBs + storage_locations TEXT, -- JSON array for both DBs + is_on_loan BOOLEAN DEFAULT FALSE, + qr_code_type TEXT CHECK (qr_code_type IN ('qr', 'barcode', 'none')), + is_disposed BOOLEAN DEFAULT FALSE, + image_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_items_label_id ON items(label_id); +CREATE INDEX IF NOT EXISTS idx_items_name ON items(name); +CREATE INDEX IF NOT EXISTS idx_items_is_on_loan ON items(is_on_loan); +CREATE INDEX IF NOT EXISTS idx_items_is_disposed ON items(is_disposed); \ No newline at end of file diff --git a/migrations/20240704000002_create_loans_table.sql b/migrations/20240704000002_create_loans_table.sql new file mode 100644 index 0000000..267efbc --- /dev/null +++ b/migrations/20240704000002_create_loans_table.sql @@ -0,0 +1,18 @@ +-- Create loans table (compatible with both PostgreSQL and SQLite) +CREATE TABLE IF NOT EXISTS loans ( + id INTEGER PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES items(id), + student_number TEXT NOT NULL, + student_name TEXT NOT NULL, + organization TEXT, + loan_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + return_date TIMESTAMP, + remarks TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_loans_item_id ON loans(item_id); +CREATE INDEX IF NOT EXISTS idx_loans_student_number ON loans(student_number); +CREATE INDEX IF NOT EXISTS idx_loans_return_date ON loans(return_date); \ No newline at end of file diff --git a/migrations/20250705000001_create_cable_colors_table.sql b/migrations/20250705000001_create_cable_colors_table.sql new file mode 100644 index 0000000..9e14f8b --- /dev/null +++ b/migrations/20250705000001_create_cable_colors_table.sql @@ -0,0 +1,24 @@ +-- Create cable colors table +CREATE TABLE cable_colors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE, + hex_code VARCHAR(7), + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Insert common cable colors +INSERT INTO cable_colors (name, hex_code, description) VALUES +('赤', '#FF0000', '赤色'), +('青', '#0000FF', '青色'), +('緑', '#00FF00', '緑色'), +('黄', '#FFFF00', '黄色'), +('黒', '#000000', '黒色'), +('白', '#FFFFFF', '白色'), +('グレー', '#808080', 'グレー色'), +('オレンジ', '#FFA500', 'オレンジ色'), +('紫', '#800080', '紫色'), +('茶', '#A52A2A', '茶色'), +('ピンク', '#FFC0CB', 'ピンク色'), +('シルバー', '#C0C0C0', 'シルバー色'); \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..03ced37 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,152 @@ +use config::{Config as ConfigBuilder, ConfigError, Environment, File}; +use serde::Deserialize; +use std::env; + +#[derive(Debug, Deserialize, Clone)] +pub struct Config { + pub database: DatabaseConfig, + pub server: ServerConfig, + pub storage: StorageConfig, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DatabaseConfig { + pub url: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ServerConfig { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct StorageConfig { + #[serde(rename = "type")] + pub storage_type: StorageType, + pub local: Option, + pub s3: Option, + #[serde(default = "default_max_file_size")] + pub max_file_size_mb: u64, +} + +fn default_max_file_size() -> u64 { + 5 // Default 5MB +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum StorageType { + Local, + S3, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LocalStorageConfig { + pub path: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct S3Config { + pub bucket_name: String, + pub region: String, + pub access_key_id: Option, + pub secret_access_key: Option, +} + +impl Config { + pub fn new() -> Result { + let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); + + let s = ConfigBuilder::builder() + // Start off by merging in the "default" configuration file + .add_source(File::with_name("config/default").required(false)) + // Add in the current environment file + // Default to 'development' env + .add_source(File::with_name(&format!("config/{}", run_mode)).required(false)) + // Add in a local configuration file + // This file shouldn't be checked in to git + .add_source(File::with_name("config/local").required(false)) + // Add in settings from the environment (with a prefix of HYPERDASHI) + // Eg.. `HYPERDASHI_DEBUG=1 ./target/app` would set the `debug` key + .add_source(Environment::with_prefix("HYPERDASHI").separator("_")) + // You can override settings from env variables + .add_source( + Environment::default() + .try_parsing(true) + .separator("_") + .list_separator(" ") + ) + .build()?; + + // You can deserialize (and thus freeze) the entire configuration as + s.try_deserialize() + } + + pub fn from_env() -> Result { + // Load .env file if it exists + dotenvy::dotenv().ok(); + + let database_url = env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite://hyperdashi.db".to_string()); + + let server_host = env::var("SERVER_HOST") + .unwrap_or_else(|_| "127.0.0.1".to_string()); + + let server_port = env::var("SERVER_PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse::() + .unwrap_or(8080); + + let storage_type = env::var("STORAGE_TYPE") + .unwrap_or_else(|_| "local".to_string()); + + let max_file_size_mb = env::var("STORAGE_MAX_FILE_SIZE_MB") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(5); + + let storage = match storage_type.to_lowercase().as_str() { + "s3" => { + let bucket_name = env::var("S3_BUCKET_NAME") + .map_err(|_| ConfigError::Message("S3_BUCKET_NAME not set".to_string()))?; + let region = env::var("AWS_REGION") + .map_err(|_| ConfigError::Message("AWS_REGION not set".to_string()))?; + let access_key_id = env::var("AWS_ACCESS_KEY_ID").ok(); + let secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok(); + + StorageConfig { + storage_type: StorageType::S3, + local: None, + s3: Some(S3Config { + bucket_name, + region, + access_key_id, + secret_access_key, + }), + max_file_size_mb, + } + } + _ => { + let path = env::var("LOCAL_STORAGE_PATH") + .unwrap_or_else(|_| "./uploads".to_string()); + + StorageConfig { + storage_type: StorageType::Local, + local: Some(LocalStorageConfig { path }), + s3: None, + max_file_size_mb, + } + } + }; + + Ok(Config { + database: DatabaseConfig { url: database_url }, + server: ServerConfig { + host: server_host, + port: server_port, + }, + storage, + }) + } +} \ No newline at end of file diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..e4bc300 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,85 @@ +use sqlx::postgres::{PgPool, PgPoolOptions}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use std::str::FromStr; + +use crate::config::Config; +use crate::error::AppResult; + +#[derive(Clone)] +pub enum DatabasePool { + Postgres(PgPool), + Sqlite(SqlitePool), +} + +impl DatabasePool { + pub async fn new(config: &Config) -> AppResult { + let database_url = &config.database.url; + + if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(database_url) + .await?; + + Ok(DatabasePool::Postgres(pool)) + } else if database_url.starts_with("sqlite://") { + let options = SqliteConnectOptions::from_str(database_url)? + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(10) + .connect_with(options) + .await?; + + Ok(DatabasePool::Sqlite(pool)) + } else { + Err(crate::error::AppError::ConfigError( + config::ConfigError::Message( + "Invalid database URL. Must start with postgres:// or sqlite://".to_string() + ) + )) + } + } + + pub async fn migrate(&self) -> AppResult<()> { + match self { + DatabasePool::Postgres(pool) => { + sqlx::migrate!("./migrations") + .run(pool) + .await + .map_err(|e| crate::error::AppError::DatabaseError(sqlx::Error::Migrate(Box::new(e))))?; + } + DatabasePool::Sqlite(pool) => { + sqlx::migrate!("./migrations") + .run(pool) + .await + .map_err(|e| crate::error::AppError::DatabaseError(sqlx::Error::Migrate(Box::new(e))))?; + } + } + Ok(()) + } + + pub fn postgres(&self) -> Option<&PgPool> { + match self { + DatabasePool::Postgres(pool) => Some(pool), + _ => None, + } + } + + pub fn sqlite(&self) -> Option<&SqlitePool> { + match self { + DatabasePool::Sqlite(pool) => Some(pool), + _ => None, + } + } +} + +#[macro_export] +macro_rules! query_as { + ($query:expr, $pool:expr) => { + match $pool { + $crate::db::DatabasePool::Postgres(pool) => sqlx::query_as!($query).fetch_all(pool).await, + $crate::db::DatabasePool::Sqlite(pool) => sqlx::query_as!($query).fetch_all(pool).await, + } + }; +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b68a7aa --- /dev/null +++ b/src/error.rs @@ -0,0 +1,101 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use std::fmt; + +#[derive(Debug)] +pub enum AppError { + NotFound(String), + BadRequest(String), + InternalServerError(String), + DatabaseError(sqlx::Error), + ConfigError(config::ConfigError), + IoError(std::io::Error), + ValidationError(String), + StorageError(String), +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppError::NotFound(msg) => write!(f, "Not found: {}", msg), + AppError::BadRequest(msg) => write!(f, "Bad request: {}", msg), + AppError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg), + AppError::DatabaseError(err) => write!(f, "Database error: {}", err), + AppError::ConfigError(err) => write!(f, "Configuration error: {}", err), + AppError::IoError(err) => write!(f, "IO error: {}", err), + AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), + AppError::StorageError(msg) => write!(f, "Storage error: {}", msg), + } + } +} + +impl std::error::Error for AppError {} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg), + AppError::InternalServerError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + AppError::DatabaseError(ref err) => { + tracing::error!("Database error: {:?}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error occurred".to_string(), + ) + } + AppError::ConfigError(ref err) => { + tracing::error!("Config error: {:?}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Configuration error occurred".to_string(), + ) + } + AppError::IoError(ref err) => { + tracing::error!("IO error: {:?}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "IO error occurred".to_string(), + ) + } + AppError::StorageError(ref msg) => { + tracing::error!("Storage error: {}", msg); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Storage error occurred".to_string(), + ) + } + }; + + let body = Json(json!({ + "error": error_message, + })); + + (status, body).into_response() + } +} + +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + AppError::DatabaseError(err) + } +} + +impl From for AppError { + fn from(err: config::ConfigError) -> Self { + AppError::ConfigError(err) + } +} + +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::IoError(err) + } +} + +pub type AppResult = Result; \ No newline at end of file diff --git a/src/handlers/cable_colors.rs b/src/handlers/cable_colors.rs new file mode 100644 index 0000000..44cbec6 --- /dev/null +++ b/src/handlers/cable_colors.rs @@ -0,0 +1,78 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use std::sync::Arc; +use validator::Validate; + +use crate::error::AppResult; +use crate::models::{CableColor, CableColorsListResponse, CreateCableColorRequest, UpdateCableColorRequest}; +use crate::services::{CableColorService, ItemService, LoanService, StorageService}; + +#[derive(Deserialize)] +pub struct CableColorsQuery { + #[serde(default = "default_page")] + pub page: u32, + #[serde(default = "default_per_page")] + pub per_page: u32, +} + +fn default_page() -> u32 { + 1 +} + +fn default_per_page() -> u32 { + 20 +} + +pub async fn list_cable_colors( + State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Query(params): Query, +) -> AppResult> { + let response = cable_color_service + .list_cable_colors(params.page, params.per_page) + .await?; + + Ok(Json(response)) +} + +pub async fn get_cable_color( + State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, +) -> AppResult> { + let cable_color = cable_color_service.get_cable_color(id).await?; + Ok(Json(cable_color)) +} + +pub async fn create_cable_color( + State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + req.validate() + .map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?; + + let cable_color = cable_color_service.create_cable_color(req).await?; + Ok((StatusCode::CREATED, Json(cable_color))) +} + +pub async fn update_cable_color( + State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + req.validate() + .map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?; + + let cable_color = cable_color_service.update_cable_color(id, req).await?; + Ok(Json(cable_color)) +} + +pub async fn delete_cable_color( + State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, +) -> AppResult { + cable_color_service.delete_cable_color(id).await?; + Ok(StatusCode::NO_CONTENT) +} \ No newline at end of file diff --git a/src/handlers/images.rs b/src/handlers/images.rs new file mode 100644 index 0000000..1cc93e2 --- /dev/null +++ b/src/handlers/images.rs @@ -0,0 +1,87 @@ +use axum::{ + extract::{Multipart, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::error::AppResult; +use crate::services::{ItemService, LoanService, StorageService, CableColorService}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ImageUploadResponse { + pub url: String, + pub filename: String, + pub size: usize, +} + +pub async fn upload_image( + State((_cable_color_service, _item_service, _loan_service, storage_service)): State<(Arc, Arc, Arc, Arc)>, + mut multipart: Multipart, +) -> AppResult<(StatusCode, Json)> { + while let Some(field) = multipart.next_field().await.map_err(|e| { + crate::error::AppError::BadRequest(format!("Failed to read multipart field: {}", e)) + })? { + let name = field.name().unwrap_or("").to_string(); + + if name == "image" { + let filename = field.file_name() + .ok_or_else(|| crate::error::AppError::BadRequest("No filename provided".to_string()))? + .to_string(); + + let content_type = field.content_type() + .unwrap_or("application/octet-stream") + .to_string(); + + // 画像ファイルの検証 + if !is_image_content_type(&content_type) { + return Err(crate::error::AppError::BadRequest( + "Only image files are allowed (JPEG, PNG, GIF, WebP)".to_string() + )); + } + + let data = field.bytes().await.map_err(|e| { + crate::error::AppError::BadRequest(format!("Failed to read file data: {}", e)) + })?; + + // ファイルサイズ制限 (5MB) + const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; + if data.len() > MAX_FILE_SIZE { + return Err(crate::error::AppError::BadRequest( + "File size exceeds 5MB limit".to_string() + )); + } + + // ユニークなファイル名を生成 + let unique_filename = generate_unique_filename(&filename); + + // ストレージにアップロード + let url = storage_service.upload(data.to_vec(), &unique_filename, &content_type).await?; + + return Ok((StatusCode::CREATED, Json(ImageUploadResponse { + url, + filename: unique_filename, + size: data.len(), + }))); + } + } + + Err(crate::error::AppError::BadRequest("No image field found in multipart data".to_string())) +} + +fn is_image_content_type(content_type: &str) -> bool { + matches!(content_type, + "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp" + ) +} + +fn generate_unique_filename(original_filename: &str) -> String { + let timestamp = chrono::Utc::now().timestamp_millis(); + let extension = std::path::Path::new(original_filename) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("jpg"); + + format!("{}_{}.{}", timestamp, uuid::Uuid::new_v4(), extension) +} \ No newline at end of file diff --git a/src/handlers/items.rs b/src/handlers/items.rs new file mode 100644 index 0000000..b758a05 --- /dev/null +++ b/src/handlers/items.rs @@ -0,0 +1,130 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use validator::Validate; + +use crate::error::AppResult; +use crate::models::{CreateItemRequest, Item, ItemsListResponse, UpdateItemRequest}; +use crate::services::{ItemService, LoanService, StorageService, CableColorService}; + +#[derive(Deserialize)] +pub struct ItemsQuery { + #[serde(default = "default_page")] + pub page: u32, + #[serde(default = "default_per_page")] + pub per_page: u32, + pub search: Option, + pub is_on_loan: Option, + pub is_disposed: Option, +} + +fn default_page() -> u32 { + 1 +} + +fn default_per_page() -> u32 { + 20 +} + +pub async fn list_items( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Query(params): Query, +) -> AppResult> { + let response = item_service + .list_items( + params.page, + params.per_page, + params.search, + params.is_on_loan, + params.is_disposed, + ) + .await?; + + Ok(Json(response)) +} + +pub async fn get_item( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, +) -> AppResult> { + let item = item_service.get_item(id).await?; + Ok(Json(item)) +} + +pub async fn get_item_by_label( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(label_id): Path, +) -> AppResult> { + let item = item_service.get_item_by_label(&label_id).await?; + Ok(Json(item)) +} + +pub async fn create_item( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + req.validate() + .map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?; + + let item = item_service.create_item(req).await?; + Ok((StatusCode::CREATED, Json(item))) +} + +pub async fn update_item( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + req.validate() + .map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?; + + let item = item_service.update_item(id, req).await?; + Ok(Json(item)) +} + +pub async fn delete_item( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, +) -> AppResult { + item_service.delete_item(id).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn dispose_item( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, +) -> AppResult> { + let item = item_service.dispose_item(id).await?; + Ok(Json(item)) +} + +pub async fn undispose_item( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, +) -> AppResult> { + let item = item_service.undispose_item(id).await?; + Ok(Json(item)) +} + +#[derive(Serialize)] +pub struct SuggestionsResponse { + pub suggestions: Vec, +} + +pub async fn get_connection_names_suggestions( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, +) -> AppResult> { + let suggestions = item_service.get_connection_names_suggestions().await?; + Ok(Json(SuggestionsResponse { suggestions })) +} + +pub async fn get_storage_locations_suggestions( + State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, +) -> AppResult> { + let suggestions = item_service.get_storage_locations_suggestions().await?; + Ok(Json(SuggestionsResponse { suggestions })) +} \ No newline at end of file diff --git a/src/handlers/loans.rs b/src/handlers/loans.rs new file mode 100644 index 0000000..0780df2 --- /dev/null +++ b/src/handlers/loans.rs @@ -0,0 +1,79 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; +use std::sync::Arc; +use validator::Validate; + +use crate::error::AppResult; +use crate::models::{CreateLoanRequest, Loan, LoansListResponse, ReturnLoanRequest}; +use crate::services::{ItemService, LoanService, StorageService, CableColorService}; + +#[derive(Deserialize)] +pub struct LoansQuery { + #[serde(default = "default_page")] + pub page: u32, + #[serde(default = "default_per_page")] + pub per_page: u32, + pub item_id: Option, + pub student_number: Option, + pub active_only: Option, +} + +fn default_page() -> u32 { + 1 +} + +fn default_per_page() -> u32 { + 20 +} + +pub async fn list_loans( + State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Query(params): Query, +) -> AppResult> { + let response = loan_service + .list_loans( + params.page, + params.per_page, + params.item_id, + params.student_number, + params.active_only, + ) + .await?; + + Ok(Json(response)) +} + +pub async fn get_loan( + State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, +) -> AppResult> { + let loan = loan_service.get_loan(id).await?; + Ok(Json(loan)) +} + +pub async fn create_loan( + State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + req.validate() + .map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?; + + let loan = loan_service.create_loan(req).await?; + Ok((StatusCode::CREATED, Json(loan))) +} + +pub async fn return_loan( + State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc, Arc, Arc, Arc)>, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + req.validate() + .map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?; + + let loan = loan_service.return_loan(id, req).await?; + Ok(Json(loan)) +} \ No newline at end of file diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..34cf4aa --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,15 @@ +pub mod items; +pub mod loans; +pub mod images; +pub mod cable_colors; + +pub use items::*; +pub use loans::*; +pub use images::*; +pub use cable_colors::*; + +use std::sync::Arc; +use crate::config::Config; +use crate::services::{ItemService, LoanService, StorageService, CableColorService}; + +pub type AppState = (Arc, Arc, Arc, Arc, Arc); \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a39bebd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,111 @@ +use axum::{ + routing::{delete, get, post, put}, + Router, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::CorsLayer; +use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; +use tracing::info; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod config; +mod db; +mod error; +mod handlers; +mod models; +mod services; + +use crate::config::{Config, StorageType}; +use crate::db::DatabasePool; +use crate::services::{ItemService, LoanService, StorageService, CableColorService}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize tracing + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "hyperdashi_server=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + info!("Starting HyperDashi server..."); + + // Load configuration + let config = Config::from_env()?; + info!("Configuration loaded: {:?}", config); + + // Initialize database connection + let db_pool = DatabasePool::new(&config).await?; + info!("Database connection established"); + + // Run migrations + db_pool.migrate().await?; + info!("Database migrations completed"); + + // Initialize storage + let storage = Arc::new(StorageService::new(&config).await?); + info!("Storage initialized"); + + // Initialize services + let cable_color_service = Arc::new(CableColorService::new(db_pool.clone())); + let item_service = Arc::new(ItemService::new(db_pool.clone())); + let loan_service = Arc::new(LoanService::new(db_pool.clone())); + + // Build application routes + let mut app = Router::new() + .route("/", get(root)) + .route("/api/v1/health", get(health_check)) + // Item routes + .route("/api/v1/items", get(handlers::list_items).post(handlers::create_item)) + .route("/api/v1/items/:id", get(handlers::get_item).put(handlers::update_item).delete(handlers::delete_item)) + .route("/api/v1/items/:id/dispose", post(handlers::dispose_item)) + .route("/api/v1/items/:id/undispose", post(handlers::undispose_item)) + .route("/api/v1/items/by-label/:label_id", get(handlers::get_item_by_label)) + .route("/api/v1/items/suggestions/connection_names", get(handlers::get_connection_names_suggestions)) + .route("/api/v1/items/suggestions/storage_locations", get(handlers::get_storage_locations_suggestions)) + // Cable color routes + .route("/api/v1/cable_colors", get(handlers::list_cable_colors).post(handlers::create_cable_color)) + .route("/api/v1/cable_colors/:id", get(handlers::get_cable_color).put(handlers::update_cable_color).delete(handlers::delete_cable_color)) + // Loan routes + .route("/api/v1/loans", get(handlers::list_loans).post(handlers::create_loan)) + .route("/api/v1/loans/:id", get(handlers::get_loan)) + .route("/api/v1/loans/:id/return", post(handlers::return_loan)) + // Image routes + .route("/api/v1/images/upload", post(handlers::upload_image)) + // Add state - combine services + .with_state((cable_color_service, item_service, loan_service, storage)) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); + + // Add static file serving for local storage + if matches!(config.storage.storage_type, StorageType::Local) { + if let Some(local_config) = &config.storage.local { + info!("Enabling static file serving for uploads at {}", local_config.path); + app = app.nest_service("/uploads", ServeDir::new(&local_config.path)); + } + } + + // Start server + let addr = SocketAddr::from(( + config.server.host.parse::()?, + config.server.port, + )); + info!("Server listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn root() -> &'static str { + "HyperDashi Server" +} + +async fn health_check() -> &'static str { + "OK" +} \ No newline at end of file diff --git a/src/models/cable_color.rs b/src/models/cable_color.rs new file mode 100644 index 0000000..bfd42fa --- /dev/null +++ b/src/models/cable_color.rs @@ -0,0 +1,46 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Serialize, Deserialize)] +pub struct CableColor { + pub id: i64, + pub name: String, + pub hex_code: Option, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct CreateCableColorRequest { + #[validate(length(min = 1, max = 100))] + pub name: String, + #[validate(regex(path = "*crate::models::cable_color::HEX_COLOR_REGEX"))] + pub hex_code: Option, + pub description: Option, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateCableColorRequest { + #[validate(length(min = 1, max = 100))] + pub name: Option, + #[validate(regex(path = "*crate::models::cable_color::HEX_COLOR_REGEX"))] + pub hex_code: Option, + pub description: Option, +} + +#[derive(Debug, Serialize)] +pub struct CableColorsListResponse { + pub cable_colors: Vec, + pub total: i64, + pub page: u32, + pub per_page: u32, +} + +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + pub static ref HEX_COLOR_REGEX: Regex = Regex::new(r"^#[0-9A-Fa-f]{6}$").unwrap(); +} \ No newline at end of file diff --git a/src/models/item.rs b/src/models/item.rs new file mode 100644 index 0000000..a9bb90c --- /dev/null +++ b/src/models/item.rs @@ -0,0 +1,107 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: i64, + pub name: String, + pub label_id: String, + pub model_number: Option, + pub remarks: Option, + pub purchase_year: Option, + pub purchase_amount: Option, + pub durability_years: Option, + pub is_depreciation_target: Option, + pub connection_names: Option>, + pub cable_color_pattern: Option>, + pub storage_locations: Option>, + pub is_on_loan: Option, + pub qr_code_type: Option, + pub is_disposed: Option, + pub image_url: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct CreateItemRequest { + #[validate(length(min = 1, max = 255))] + pub name: String, + + #[validate(length(min = 1, max = 50))] + pub label_id: String, + + #[validate(length(max = 255))] + pub model_number: Option, + + pub remarks: Option, + + #[validate(range(min = 1900, max = 2100))] + pub purchase_year: Option, + + pub purchase_amount: Option, + + #[validate(range(min = 1, max = 100))] + pub durability_years: Option, + + pub is_depreciation_target: Option, + + pub connection_names: Option>, + + pub cable_color_pattern: Option>, + + pub storage_locations: Option>, + + pub qr_code_type: Option, + + #[validate(url)] + pub image_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct UpdateItemRequest { + #[validate(length(min = 1, max = 255))] + pub name: Option, + + #[validate(length(min = 1, max = 50))] + pub label_id: Option, + + #[validate(length(max = 255))] + pub model_number: Option, + + pub remarks: Option, + + #[validate(range(min = 1900, max = 2100))] + pub purchase_year: Option, + + pub purchase_amount: Option, + + #[validate(range(min = 1, max = 100))] + pub durability_years: Option, + + pub is_depreciation_target: Option, + + pub connection_names: Option>, + + pub cable_color_pattern: Option>, + + pub storage_locations: Option>, + + pub is_on_loan: Option, + + pub qr_code_type: Option, + + pub is_disposed: Option, + + #[validate(url)] + pub image_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ItemsListResponse { + pub items: Vec, + pub total: i64, + pub page: u32, + pub per_page: u32, +} \ No newline at end of file diff --git a/src/models/loan.rs b/src/models/loan.rs new file mode 100644 index 0000000..222848e --- /dev/null +++ b/src/models/loan.rs @@ -0,0 +1,72 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Loan { + pub id: i64, + pub item_id: i64, + pub student_number: String, + pub student_name: String, + pub organization: Option, + pub loan_date: DateTime, + pub return_date: Option>, + pub remarks: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct CreateLoanRequest { + pub item_id: i64, + + #[validate(length(min = 1, max = 20))] + pub student_number: String, + + #[validate(length(min = 1, max = 100))] + pub student_name: String, + + #[validate(length(max = 255))] + pub organization: Option, + + pub remarks: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct ReturnLoanRequest { + pub return_date: Option>, + pub remarks: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoanWithItem { + pub id: i64, + pub item_id: i64, + pub item_name: String, + pub item_label_id: String, + pub student_number: String, + pub student_name: String, + pub organization: Option, + pub loan_date: DateTime, + pub return_date: Option>, + pub remarks: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoansListResponse { + pub loans: Vec, + pub total: i64, + pub page: u32, + pub per_page: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoanFilters { + pub item_id: Option, + pub student_number: Option, + pub active_only: Option, + pub page: Option, + pub per_page: Option, +} \ No newline at end of file diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..30865ce --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,7 @@ +pub mod item; +pub mod loan; +pub mod cable_color; + +pub use item::*; +pub use loan::*; +pub use cable_color::*; \ No newline at end of file diff --git a/src/services/cable_color_service.rs b/src/services/cable_color_service.rs new file mode 100644 index 0000000..ec2ba72 --- /dev/null +++ b/src/services/cable_color_service.rs @@ -0,0 +1,170 @@ +use crate::db::DatabasePool; +use crate::error::{AppError, AppResult}; +use crate::models::{CableColor, CableColorsListResponse, CreateCableColorRequest, UpdateCableColorRequest}; +use sqlx::Row; + +pub struct CableColorService { + db: DatabasePool, +} + +impl CableColorService { + pub fn new(db: DatabasePool) -> Self { + Self { db } + } + + pub async fn create_cable_color(&self, req: CreateCableColorRequest) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let result = sqlx::query( + r#" + INSERT INTO cable_colors (name, hex_code, description) + VALUES (?1, ?2, ?3) + "#, + ) + .bind(&req.name) + .bind(&req.hex_code) + .bind(&req.description) + .execute(pool) + .await?; + + let id = result.last_insert_rowid(); + self.get_cable_color(id).await + } + } + } + + pub async fn get_cable_color(&self, id: i64) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let row = sqlx::query( + r#" + SELECT id, name, hex_code, description, created_at, updated_at + FROM cable_colors + WHERE id = ?1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Cable color with id {} not found", id)))?; + + Ok(self.row_to_cable_color(row)) + } + } + } + + pub async fn list_cable_colors( + &self, + page: u32, + per_page: u32, + ) -> AppResult { + let offset = ((page - 1) * per_page) as i64; + let limit = per_page as i64; + + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let rows = sqlx::query( + r#" + SELECT id, name, hex_code, description, created_at, updated_at + FROM cable_colors + ORDER BY created_at DESC + LIMIT ?1 OFFSET ?2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let cable_colors: Vec = rows.into_iter() + .map(|row| self.row_to_cable_color(row)) + .collect(); + + let count_row = sqlx::query("SELECT COUNT(*) as count FROM cable_colors") + .fetch_one(pool) + .await?; + let total: i64 = count_row.get("count"); + + Ok(CableColorsListResponse { + cable_colors, + total, + page, + per_page, + }) + } + } + } + + pub async fn update_cable_color(&self, id: i64, req: UpdateCableColorRequest) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + // まず色が存在するかチェック + let _existing_color = self.get_cable_color(id).await?; + + let now = chrono::Utc::now(); + + sqlx::query( + r#" + UPDATE cable_colors SET + name = COALESCE(?2, name), + hex_code = COALESCE(?3, hex_code), + description = COALESCE(?4, description), + updated_at = ?5 + WHERE id = ?1 + "#, + ) + .bind(id) + .bind(&req.name) + .bind(&req.hex_code) + .bind(&req.description) + .bind(now) + .execute(pool) + .await?; + + self.get_cable_color(id).await + } + } + } + + pub async fn delete_cable_color(&self, id: i64) -> AppResult<()> { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let result = sqlx::query("DELETE FROM cable_colors WHERE id = ?1") + .bind(id) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("Cable color with id {} not found", id))); + } + Ok(()) + } + } + } + + fn row_to_cable_color(&self, row: sqlx::sqlite::SqliteRow) -> CableColor { + CableColor { + id: row.get("id"), + name: row.get("name"), + hex_code: row.get("hex_code"), + description: row.get("description"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + } +} \ No newline at end of file diff --git a/src/services/item_service.rs b/src/services/item_service.rs new file mode 100644 index 0000000..fa432ef --- /dev/null +++ b/src/services/item_service.rs @@ -0,0 +1,496 @@ +use crate::db::DatabasePool; +use crate::error::{AppError, AppResult}; +use crate::models::{CreateItemRequest, Item, ItemsListResponse, UpdateItemRequest}; +use chrono::Utc; +use sqlx::Row; + +pub struct ItemService { + db: DatabasePool, +} + +impl ItemService { + pub fn new(db: DatabasePool) -> Self { + Self { db } + } + + pub async fn create_item(&self, req: CreateItemRequest) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let connection_names = req.connection_names + .map(|v| serde_json::to_string(&v).unwrap_or_default()); + let cable_color_pattern = req.cable_color_pattern + .map(|v| serde_json::to_string(&v).unwrap_or_default()); + let storage_locations = req.storage_locations + .map(|v| serde_json::to_string(&v).unwrap_or_default()); + let is_depreciation_target = req.is_depreciation_target.unwrap_or(false); + + let result = sqlx::query!( + r#" + INSERT INTO items ( + name, label_id, model_number, remarks, purchase_year, + purchase_amount, durability_years, is_depreciation_target, connection_names, + cable_color_pattern, storage_locations, qr_code_type, image_url + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + "#, + req.name, + req.label_id, + req.model_number, + req.remarks, + req.purchase_year, + req.purchase_amount, + req.durability_years, + is_depreciation_target, + connection_names, + cable_color_pattern, + storage_locations, + req.qr_code_type, + req.image_url + ) + .execute(pool) + .await?; + + let id = result.last_insert_rowid(); + self.get_item(id).await + } + } + } + + pub async fn get_item(&self, id: i64) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let row = sqlx::query( + r#" + SELECT + id, name, label_id, model_number, remarks, purchase_year, + purchase_amount, durability_years, is_depreciation_target, + connection_names, cable_color_pattern, storage_locations, + is_on_loan, qr_code_type, is_disposed, image_url, + created_at, updated_at + FROM items + WHERE id = ?1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Item with id {} not found", id)))?; + + Ok(self.row_to_item(row)) + } + } + } + + pub async fn get_item_by_label(&self, label_id: &str) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let row = sqlx::query( + r#" + SELECT + id, name, label_id, model_number, remarks, purchase_year, + purchase_amount, durability_years, is_depreciation_target, + connection_names, cable_color_pattern, storage_locations, + is_on_loan, qr_code_type, is_disposed, image_url, + created_at, updated_at + FROM items + WHERE label_id = ?1 + "#, + ) + .bind(label_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Item with label_id {} not found", label_id)))?; + + Ok(self.row_to_item(row)) + } + } + } + + pub async fn list_items( + &self, + page: u32, + per_page: u32, + search: Option, + is_on_loan: Option, + is_disposed: Option, + ) -> AppResult { + let offset = ((page - 1) * per_page) as i64; + let limit = per_page as i64; + + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + // 動的WHEREクエリを構築(簡単な方法) + let mut where_conditions = Vec::new(); + + // 検索条件 + if search.is_some() { + where_conditions.push("(name LIKE ? OR label_id LIKE ? OR model_number LIKE ? OR remarks LIKE ?)".to_string()); + } + + // 貸出状態フィルター + if is_on_loan.is_some() { + where_conditions.push("is_on_loan = ?".to_string()); + } + + // 廃棄状態フィルター + if is_disposed.is_some() { + where_conditions.push("is_disposed = ?".to_string()); + } + + let where_clause = if where_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", where_conditions.join(" AND ")) + }; + + // シンプルなアプローチで実装(フィルター条件ごとに分岐) + let (items, total) = if search.is_none() && is_on_loan.is_none() && is_disposed.is_none() { + // フィルターなし + let rows = sqlx::query( + r#" + SELECT + id, name, label_id, model_number, remarks, purchase_year, + purchase_amount, durability_years, is_depreciation_target, + connection_names, cable_color_pattern, storage_locations, + is_on_loan, qr_code_type, is_disposed, image_url, + created_at, updated_at + FROM items + ORDER BY created_at DESC + LIMIT ?1 OFFSET ?2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let items: Vec = rows.into_iter() + .map(|row| self.row_to_item(row)) + .collect(); + + let count_row = sqlx::query("SELECT COUNT(*) as count FROM items") + .fetch_one(pool) + .await?; + let total: i64 = count_row.get("count"); + + (items, total) + } else { + // フィルターあり - 動的クエリを使用 + let query_str = format!( + r#" + SELECT + id, name, label_id, model_number, remarks, purchase_year, + purchase_amount, durability_years, is_depreciation_target, + connection_names, cable_color_pattern, storage_locations, + is_on_loan, qr_code_type, is_disposed, image_url, + created_at, updated_at + FROM items + {} + ORDER BY created_at DESC + LIMIT ? OFFSET ? + "#, + where_clause + ); + + let count_query_str = format!("SELECT COUNT(*) as count FROM items {}", where_clause); + + // パラメーターをバインドするためのヘルパー関数 + let mut query = sqlx::query(&query_str); + let mut count_query = sqlx::query(&count_query_str); + + // 検索条件 + if let Some(search_term) = &search { + let search_pattern = format!("%{}%", search_term); + query = query.bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern.clone()); + count_query = count_query.bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern); + } + + // 貸出状態フィルター + if let Some(loan_status) = is_on_loan { + let loan_value = if loan_status { 1i32 } else { 0i32 }; + query = query.bind(loan_value); + count_query = count_query.bind(loan_value); + } + + // 廃棄状態フィルター + if let Some(disposed_status) = is_disposed { + let disposed_value = if disposed_status { 1i32 } else { 0i32 }; + query = query.bind(disposed_value); + count_query = count_query.bind(disposed_value); + } + + // LIMIT/OFFSETをバインド + query = query.bind(limit).bind(offset); + + let rows = query.fetch_all(pool).await?; + let items: Vec = rows.into_iter() + .map(|row| self.row_to_item(row)) + .collect(); + + let count_row = count_query.fetch_one(pool).await?; + let total: i64 = count_row.get("count"); + + (items, total) + }; + + Ok(ItemsListResponse { + items, + total, + page, + per_page, + }) + } + } + } + + pub async fn update_item(&self, id: i64, req: UpdateItemRequest) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + // まず物品が存在するかチェック + let _existing_item = self.get_item(id).await?; + + // JSON配列フィールドをシリアライズ + let connection_names_json = req.connection_names + .as_ref() + .map(|names| serde_json::to_string(names)) + .transpose() + .map_err(|e| AppError::InternalServerError(format!("Failed to serialize connection_names: {}", e)))?; + + let cable_color_pattern_json = req.cable_color_pattern + .as_ref() + .map(|pattern| serde_json::to_string(pattern)) + .transpose() + .map_err(|e| AppError::InternalServerError(format!("Failed to serialize cable_color_pattern: {}", e)))?; + + let storage_locations_json = req.storage_locations + .as_ref() + .map(|locations| serde_json::to_string(locations)) + .transpose() + .map_err(|e| AppError::InternalServerError(format!("Failed to serialize storage_locations: {}", e)))?; + + let now = chrono::Utc::now(); + + sqlx::query!( + r#" + UPDATE items SET + name = COALESCE(?2, name), + label_id = COALESCE(?3, label_id), + model_number = COALESCE(?4, model_number), + remarks = COALESCE(?5, remarks), + purchase_year = COALESCE(?6, purchase_year), + purchase_amount = COALESCE(?7, purchase_amount), + durability_years = COALESCE(?8, durability_years), + is_depreciation_target = COALESCE(?9, is_depreciation_target), + connection_names = COALESCE(?10, connection_names), + cable_color_pattern = COALESCE(?11, cable_color_pattern), + storage_locations = COALESCE(?12, storage_locations), + qr_code_type = COALESCE(?13, qr_code_type), + image_url = COALESCE(?14, image_url), + updated_at = ?15 + WHERE id = ?1 + "#, + id, + req.name, + req.label_id, + req.model_number, + req.remarks, + req.purchase_year, + req.purchase_amount, + req.durability_years, + req.is_depreciation_target, + connection_names_json, + cable_color_pattern_json, + storage_locations_json, + req.qr_code_type, + req.image_url, + now + ) + .execute(pool) + .await?; + + // 更新後の物品を取得して返す + self.get_item(id).await + } + } + } + + pub async fn delete_item(&self, id: i64) -> AppResult<()> { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + // まず物品が存在し、貸出中でないかチェック + let item = self.get_item(id).await?; + + if item.is_on_loan.unwrap_or(false) { + return Err(AppError::BadRequest("Cannot delete item that is currently on loan".to_string())); + } + + // アクティブな貸出がないかチェック + let active_loans = sqlx::query!( + "SELECT COUNT(*) as count FROM loans WHERE item_id = ?1 AND return_date IS NULL", + id + ) + .fetch_one(pool) + .await?; + + if active_loans.count > 0 { + return Err(AppError::BadRequest("Cannot delete item with active loans".to_string())); + } + + let result = sqlx::query("DELETE FROM items WHERE id = ?1") + .bind(id) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("Item with id {} not found", id))); + } + Ok(()) + } + } + } + + pub async fn dispose_item(&self, id: i64) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let now = Utc::now(); + let result = sqlx::query("UPDATE items SET is_disposed = 1, updated_at = ?2 WHERE id = ?1") + .bind(id) + .bind(now) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("Item with id {} not found", id))); + } + + self.get_item(id).await + } + } + } + + pub async fn undispose_item(&self, id: i64) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let now = Utc::now(); + let result = sqlx::query("UPDATE items SET is_disposed = 0, updated_at = ?2 WHERE id = ?1") + .bind(id) + .bind(now) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("Item with id {} not found", id))); + } + + self.get_item(id).await + } + } + } + + pub async fn get_connection_names_suggestions(&self) -> AppResult> { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let rows = sqlx::query("SELECT DISTINCT connection_names FROM items WHERE connection_names IS NOT NULL AND connection_names != ''") + .fetch_all(pool) + .await?; + + let mut suggestions = Vec::new(); + for row in rows { + if let Some(json_str) = row.get::, _>("connection_names") { + if let Ok(names) = serde_json::from_str::>(&json_str) { + suggestions.extend(names); + } + } + } + + // 重複を除去してソート + suggestions.sort(); + suggestions.dedup(); + Ok(suggestions) + } + } + } + + pub async fn get_storage_locations_suggestions(&self) -> AppResult> { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let rows = sqlx::query("SELECT DISTINCT storage_locations FROM items WHERE storage_locations IS NOT NULL AND storage_locations != ''") + .fetch_all(pool) + .await?; + + let mut suggestions = Vec::new(); + for row in rows { + if let Some(json_str) = row.get::, _>("storage_locations") { + if let Ok(locations) = serde_json::from_str::>(&json_str) { + suggestions.extend(locations); + } + } + } + + // 重複を除去してソート + suggestions.sort(); + suggestions.dedup(); + Ok(suggestions) + } + } + } + + fn row_to_item(&self, row: sqlx::sqlite::SqliteRow) -> Item { + let connection_names: Option> = row.get::, _>("connection_names") + .and_then(|s| serde_json::from_str(&s).ok()); + let cable_color_pattern: Option> = row.get::, _>("cable_color_pattern") + .and_then(|s| serde_json::from_str(&s).ok()); + let storage_locations: Option> = row.get::, _>("storage_locations") + .and_then(|s| serde_json::from_str(&s).ok()); + + Item { + id: row.get("id"), + name: row.get("name"), + label_id: row.get("label_id"), + model_number: row.get("model_number"), + remarks: row.get("remarks"), + purchase_year: row.get("purchase_year"), + purchase_amount: row.get("purchase_amount"), + durability_years: row.get("durability_years"), + is_depreciation_target: row.get("is_depreciation_target"), + connection_names, + cable_color_pattern, + storage_locations, + is_on_loan: row.get("is_on_loan"), + qr_code_type: row.get("qr_code_type"), + is_disposed: row.get("is_disposed"), + image_url: row.get("image_url"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + } +} \ No newline at end of file diff --git a/src/services/loan_service.rs b/src/services/loan_service.rs new file mode 100644 index 0000000..94be0a4 --- /dev/null +++ b/src/services/loan_service.rs @@ -0,0 +1,313 @@ +use crate::db::DatabasePool; +use crate::error::{AppError, AppResult}; +use crate::models::{CreateLoanRequest, Loan, LoanWithItem, LoansListResponse, ReturnLoanRequest}; +use chrono::Utc; +use sqlx::Row; + +pub struct LoanService { + db: DatabasePool, +} + +impl LoanService { + pub fn new(db: DatabasePool) -> Self { + Self { db } + } + + pub async fn create_loan(&self, req: CreateLoanRequest) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + // まず、物品が存在し、貸出可能かチェック + let item_check = sqlx::query!( + "SELECT id, name, is_on_loan, is_disposed FROM items WHERE id = ?1", + req.item_id + ) + .fetch_optional(pool) + .await?; + + let item = item_check.ok_or_else(|| + AppError::NotFound(format!("Item with id {} not found", req.item_id)) + )?; + + if item.is_on_loan.unwrap_or(false) { + return Err(AppError::BadRequest("Item is already on loan".to_string())); + } + + if item.is_disposed.unwrap_or(false) { + return Err(AppError::BadRequest("Item is disposed and cannot be loaned".to_string())); + } + + // 貸出記録を作成 + let result = sqlx::query!( + r#" + INSERT INTO loans ( + item_id, student_number, student_name, organization, remarks + ) VALUES (?1, ?2, ?3, ?4, ?5) + "#, + req.item_id, + req.student_number, + req.student_name, + req.organization, + req.remarks + ) + .execute(pool) + .await?; + + // 物品の貸出状態を更新 + let now = Utc::now(); + sqlx::query!( + "UPDATE items SET is_on_loan = 1, updated_at = ?2 WHERE id = ?1", + req.item_id, + now + ) + .execute(pool) + .await?; + + let loan_id = result.last_insert_rowid(); + self.get_loan(loan_id).await + } + } + } + + pub async fn get_loan(&self, id: i64) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + let row = sqlx::query( + r#" + SELECT + id, item_id, student_number, student_name, organization, + loan_date, return_date, remarks, created_at, updated_at + FROM loans + WHERE id = ?1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Loan with id {} not found", id)))?; + + Ok(self.row_to_loan(row)) + } + } + } + + pub async fn list_loans( + &self, + page: u32, + per_page: u32, + item_id: Option, + student_number: Option, + active_only: Option, + ) -> AppResult { + let offset = ((page - 1) * per_page) as i64; + let limit = per_page as i64; + + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + // フィルタリング機能を実装 + let (loans, total) = if item_id.is_none() && student_number.is_none() && active_only.is_none() { + // フィルターなし + let rows = sqlx::query( + r#" + SELECT + l.id, l.item_id, l.student_number, l.student_name, l.organization, + l.loan_date, l.return_date, l.remarks, l.created_at, l.updated_at, + i.name as item_name, i.label_id as item_label_id + FROM loans l + INNER JOIN items i ON l.item_id = i.id + ORDER BY l.created_at DESC + LIMIT ?1 OFFSET ?2 + "#, + ) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let loans: Vec = rows.into_iter() + .map(|row| self.row_to_loan_with_item(row)) + .collect(); + + let count_row = sqlx::query("SELECT COUNT(*) as count FROM loans") + .fetch_one(pool) + .await?; + let total: i64 = count_row.get("count"); + + (loans, total) + } else { + // フィルターあり - 動的クエリを構築 + let mut where_conditions = Vec::new(); + + // 物品IDフィルター + if item_id.is_some() { + where_conditions.push("l.item_id = ?".to_string()); + } + + // 学籍番号フィルター + if student_number.is_some() { + where_conditions.push("l.student_number = ?".to_string()); + } + + // アクティブ貸出のみフィルター + if let Some(true) = active_only { + where_conditions.push("l.return_date IS NULL".to_string()); + } else if let Some(false) = active_only { + where_conditions.push("l.return_date IS NOT NULL".to_string()); + } + + let where_clause = if where_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", where_conditions.join(" AND ")) + }; + + let query_str = format!( + r#" + SELECT + l.id, l.item_id, l.student_number, l.student_name, l.organization, + l.loan_date, l.return_date, l.remarks, l.created_at, l.updated_at, + i.name as item_name, i.label_id as item_label_id + FROM loans l + INNER JOIN items i ON l.item_id = i.id + {} + ORDER BY l.created_at DESC + LIMIT ? OFFSET ? + "#, + where_clause + ); + + let count_query_str = format!( + "SELECT COUNT(*) as count FROM loans l INNER JOIN items i ON l.item_id = i.id {}", + where_clause + ); + + // パラメーターをバインド + let mut query = sqlx::query(&query_str); + let mut count_query = sqlx::query(&count_query_str); + + // 物品IDフィルター + if let Some(id) = item_id { + query = query.bind(id); + count_query = count_query.bind(id); + } + + // 学籍番号フィルター + if let Some(ref number) = student_number { + query = query.bind(number); + count_query = count_query.bind(number); + } + + // LIMIT/OFFSETをバインド(active_onlyは既にWHERE句に含まれている) + query = query.bind(limit).bind(offset); + + let rows = query.fetch_all(pool).await?; + let loans: Vec = rows.into_iter() + .map(|row| self.row_to_loan_with_item(row)) + .collect(); + + let count_row = count_query.fetch_one(pool).await?; + let total: i64 = count_row.get("count"); + + (loans, total) + }; + + Ok(LoansListResponse { + loans, + total, + page, + per_page, + }) + } + } + } + + pub async fn return_loan(&self, id: i64, req: ReturnLoanRequest) -> AppResult { + match &self.db { + DatabasePool::Postgres(_pool) => { + Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string())) + } + DatabasePool::Sqlite(pool) => { + // 貸出記録が存在し、未返却かチェック + let loan_check = sqlx::query!( + "SELECT id, item_id, return_date FROM loans WHERE id = ?1", + id + ) + .fetch_optional(pool) + .await?; + + let loan = loan_check.ok_or_else(|| + AppError::NotFound(format!("Loan with id {} not found", id)) + )?; + + if loan.return_date.is_some() { + return Err(AppError::BadRequest("Loan has already been returned".to_string())); + } + + let return_date = req.return_date.unwrap_or_else(|| Utc::now()); + let now = Utc::now(); + + // 貸出記録を更新 + sqlx::query!( + "UPDATE loans SET return_date = ?2, remarks = ?3, updated_at = ?4 WHERE id = ?1", + id, + return_date, + req.remarks, + now + ) + .execute(pool) + .await?; + + // 物品の貸出状態を更新 + sqlx::query!( + "UPDATE items SET is_on_loan = 0, updated_at = ?2 WHERE id = ?1", + loan.item_id, + now + ) + .execute(pool) + .await?; + + self.get_loan(id).await + } + } + } + + fn row_to_loan(&self, row: sqlx::sqlite::SqliteRow) -> Loan { + Loan { + id: row.get("id"), + item_id: row.get("item_id"), + student_number: row.get("student_number"), + student_name: row.get("student_name"), + organization: row.get("organization"), + loan_date: row.get("loan_date"), + return_date: row.get("return_date"), + remarks: row.get("remarks"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + } + + fn row_to_loan_with_item(&self, row: sqlx::sqlite::SqliteRow) -> LoanWithItem { + LoanWithItem { + id: row.get("id"), + item_id: row.get("item_id"), + item_name: row.get("item_name"), + item_label_id: row.get("item_label_id"), + student_number: row.get("student_number"), + student_name: row.get("student_name"), + organization: row.get("organization"), + loan_date: row.get("loan_date"), + return_date: row.get("return_date"), + remarks: row.get("remarks"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + } +} \ No newline at end of file diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..286be98 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,9 @@ +pub mod item_service; +pub mod loan_service; +pub mod cable_color_service; +pub mod storage; + +pub use item_service::*; +pub use loan_service::*; +pub use cable_color_service::*; +pub use storage::StorageService; \ No newline at end of file diff --git a/src/services/storage.rs b/src/services/storage.rs new file mode 100644 index 0000000..5f534db --- /dev/null +++ b/src/services/storage.rs @@ -0,0 +1,178 @@ +use aws_sdk_s3::Client as S3Client; +use std::path::{Path, PathBuf}; +use tokio::fs; +use uuid::Uuid; + +use crate::config::{Config, StorageType}; +use crate::error::{AppError, AppResult}; + +#[derive(Clone)] +pub enum StorageService { + S3(S3Storage), + Local(LocalStorage), +} + +impl StorageService { + pub async fn new(config: &Config) -> AppResult { + match &config.storage.storage_type { + StorageType::S3 => Ok(StorageService::S3(S3Storage::new(config).await?)), + StorageType::Local => Ok(StorageService::Local(LocalStorage::new(config)?)), + } + } + + pub async fn upload(&self, data: Vec, filename: &str, content_type: &str) -> AppResult { + match self { + StorageService::S3(storage) => storage.upload(data, filename, content_type).await, + StorageService::Local(storage) => storage.upload(data, filename, content_type).await, + } + } + + pub async fn delete(&self, url: &str) -> AppResult<()> { + match self { + StorageService::S3(storage) => storage.delete(url).await, + StorageService::Local(storage) => storage.delete(url).await, + } + } + + pub fn get_url(&self, key: &str) -> String { + match self { + StorageService::S3(storage) => storage.get_url(key), + StorageService::Local(storage) => storage.get_url(key), + } + } +} + +#[derive(Clone)] +pub struct S3Storage { + client: S3Client, + bucket_name: String, + base_url: String, +} + +impl S3Storage { + pub async fn new(config: &Config) -> AppResult { + let s3_config = config.storage.s3.as_ref() + .ok_or_else(|| AppError::ConfigError( + config::ConfigError::Message("S3 configuration not found".to_string()) + ))?; + + let aws_config = aws_config::defaults(aws_config::BehaviorVersion::latest()).load().await; + let client = S3Client::new(&aws_config); + + let base_url = format!("https://{}.s3.{}.amazonaws.com", + s3_config.bucket_name, + s3_config.region + ); + + Ok(Self { + client, + bucket_name: s3_config.bucket_name.clone(), + base_url, + }) + } + + pub async fn upload(&self, data: Vec, filename: &str, content_type: &str) -> AppResult { + let key = format!("images/{}/{}", Uuid::new_v4(), filename); + + self.client + .put_object() + .bucket(&self.bucket_name) + .key(&key) + .body(data.into()) + .content_type(content_type) + .send() + .await + .map_err(|e| AppError::StorageError(format!("Failed to upload to S3: {}", e)))?; + + Ok(self.get_url(&key)) + } + + pub async fn delete(&self, url: &str) -> AppResult<()> { + // Extract key from URL + let key = url.strip_prefix(&format!("{}/", self.base_url)) + .ok_or_else(|| AppError::StorageError("Invalid S3 URL".to_string()))?; + + self.client + .delete_object() + .bucket(&self.bucket_name) + .key(key) + .send() + .await + .map_err(|e| AppError::StorageError(format!("Failed to delete from S3: {}", e)))?; + + Ok(()) + } + + pub fn get_url(&self, key: &str) -> String { + format!("{}/{}", self.base_url, key) + } +} + +#[derive(Clone)] +pub struct LocalStorage { + base_path: PathBuf, + base_url: String, +} + +impl LocalStorage { + pub fn new(config: &Config) -> AppResult { + let local_config = config.storage.local.as_ref() + .ok_or_else(|| AppError::ConfigError( + config::ConfigError::Message("Local storage configuration not found".to_string()) + ))?; + + let base_path = PathBuf::from(&local_config.path); + let base_url = format!("http://{}:{}/uploads", + config.server.host, + config.server.port + ); + + Ok(Self { + base_path, + base_url, + }) + } + + async fn ensure_directory(&self, path: &Path) -> AppResult<()> { + if !path.exists() { + fs::create_dir_all(path).await?; + } + Ok(()) + } + + pub async fn upload(&self, data: Vec, filename: &str, _content_type: &str) -> AppResult { + let dir_name = Uuid::new_v4().to_string(); + let dir_path = self.base_path.join(&dir_name); + + self.ensure_directory(&dir_path).await?; + + let file_path = dir_path.join(filename); + fs::write(&file_path, data).await?; + + let relative_path = format!("{}/{}", dir_name, filename); + Ok(self.get_url(&relative_path)) + } + + pub async fn delete(&self, url: &str) -> AppResult<()> { + // Extract relative path from URL + let relative_path = url.strip_prefix(&format!("{}/", self.base_url)) + .ok_or_else(|| AppError::StorageError("Invalid local storage URL".to_string()))?; + + let file_path = self.base_path.join(relative_path); + + if file_path.exists() { + fs::remove_file(&file_path).await?; + + // Try to remove empty parent directory + if let Some(parent) = file_path.parent() { + let _ = fs::remove_dir(parent).await; + } + } + + Ok(()) + } + + pub fn get_url(&self, key: &str) -> String { + format!("{}/{}", self.base_url, key) + } +} \ No newline at end of file diff --git a/test_item.json b/test_item.json new file mode 100644 index 0000000..fd99645 --- /dev/null +++ b/test_item.json @@ -0,0 +1 @@ +{"name":"テストマイク","label_id":"MIC001","model_number":"Sony WM-1000XM4","remarks":"テスト用","purchase_year":2023,"purchase_amount":35000.0,"durability_years":5,"is_depreciation_target":true,"qr_code_type":"qr"}