From 1ddffbc0266f4644b1579a1c5192478a0031803e Mon Sep 17 00:00:00 2001 From: Sosokker Date: Mon, 21 Apr 2025 00:10:30 +0700 Subject: [PATCH] feat: store attachment --- backend/Makefile | 4 - backend/cmd/server/main.go | 9 +- backend/example.config.yaml | 9 +- backend/go.mod | 4 +- backend/go.sum | 37 +- backend/internal/api/handlers.go | 219 ++++--- backend/internal/api/openapi_types.go | 114 ++-- backend/internal/domain/attachment.go | 10 - backend/internal/domain/todo.go | 34 +- backend/internal/repository/interfaces.go | 8 +- .../repository/sqlc/queries/attachments.sql | 17 - .../repository/sqlc/queries/todos.sql | 26 +- backend/internal/repository/todo_repo.go | 79 +-- backend/internal/service/gcs_storage.go | 146 +++-- backend/internal/service/interfaces.go | 18 +- backend/internal/service/local_storage.go | 185 ------ backend/internal/service/todo_service.go | 176 +++--- .../000002_add_single_attachment_url.down.sql | 20 + .../000002_add_single_attachment_url.up.sql | 14 + backend/openapi.yaml | 448 +++++--------- frontend/components/icons.tsx | 1 + frontend/components/navbar.tsx | 68 +-- frontend/components/todo-card.tsx | 406 ++++++------- frontend/components/todo-form.tsx | 190 ++++-- frontend/components/todo-row.tsx | 545 +++++++++++++----- frontend/hooks/use-tags.ts | 2 +- frontend/next.config.ts | 9 +- frontend/services/api-attachments.ts | 44 ++ frontend/services/api-client.ts | 177 ++++-- frontend/services/api-types.ts | 25 +- 30 files changed, 1622 insertions(+), 1422 deletions(-) delete mode 100644 backend/internal/domain/attachment.go delete mode 100644 backend/internal/repository/sqlc/queries/attachments.sql delete mode 100644 backend/internal/service/local_storage.go create mode 100644 backend/migrations/000002_add_single_attachment_url.down.sql create mode 100644 backend/migrations/000002_add_single_attachment_url.up.sql create mode 100644 frontend/services/api-attachments.ts diff --git a/backend/Makefile b/backend/Makefile index b228e36..76b9b14 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -73,18 +73,14 @@ test: migrate-up: @echo ">> Applying migrations..." - @if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) up migrate-down: @echo ">> Rolling back last migration..." - @if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) down 1 migrate-force: @echo ">> Forcing migration version $(VERSION)..." - @if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi - @if [ -z "$(VERSION)" ]; then echo "Error: VERSION is not set. Usage: make migrate-force VERSION="; exit 1; fi $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION) clean: diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b4ccc93..d9d6023 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -56,14 +56,7 @@ func main() { repoRegistry := repository.NewRepositoryRegistry(pool) var storageService service.FileStorageService - switch cfg.Storage.Type { - case "local": - storageService, err = service.NewLocalStorageService(cfg.Storage.Local, logger) - case "gcs": - storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger) - default: - err = fmt.Errorf("unsupported storage type: %s", cfg.Storage.Type) - } + storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger) if err != nil { logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type) os.Exit(1) diff --git a/backend/example.config.yaml b/backend/example.config.yaml index 37aa318..f60145d 100644 --- a/backend/example.config.yaml +++ b/backend/example.config.yaml @@ -7,6 +7,9 @@ server: idleTimeout: 60s basePath: "/api/v1" # Matches OpenAPI server URL +frontend: + url: "http://localhost:3000" + database: url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod @@ -39,7 +42,5 @@ cache: cleanupInterval: 10m storage: - local: - path: "/" # gcs: - # bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME - # credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS + bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME + credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS diff --git a/backend/go.mod b/backend/go.mod index 666586d..c13e114 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -26,6 +26,7 @@ tool ( ) require ( + cloud.google.com/go/storage v1.51.0 github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.1 github.com/golang-jwt/jwt/v5 v5.2.2 @@ -37,6 +38,7 @@ require ( github.com/spf13/viper v1.20.1 golang.org/x/crypto v0.37.0 golang.org/x/oauth2 v0.29.0 + google.golang.org/api v0.229.0 ) require ( @@ -47,7 +49,6 @@ require ( cloud.google.com/go/compute/metadata v0.6.0 // indirect cloud.google.com/go/iam v1.4.1 // indirect cloud.google.com/go/monitoring v1.24.0 // indirect - cloud.google.com/go/storage v1.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect @@ -114,7 +115,6 @@ require ( golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.24.0 // indirect - google.golang.org/api v0.229.0 // indirect google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect diff --git a/backend/go.sum b/backend/go.sum index 0b8b339..f1c0aa0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,6 +1,5 @@ cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= @@ -11,16 +10,24 @@ cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4 cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= +cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= +cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= +cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= +cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -58,8 +65,11 @@ github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -113,12 +123,16 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= @@ -209,8 +223,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= @@ -255,24 +269,18 @@ go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//sn go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= -go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= -go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= -go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= @@ -295,8 +303,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= @@ -343,7 +349,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8= google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index c3baf0e..fd0b1ab 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -139,7 +139,7 @@ func mapDomainTagToApi(tag *domain.Tag) *models.Tag { UpdatedAt: &updatedAt} } -func mapDomainTodoToApi(todo *domain.Todo) *models.Todo { +func mapDomainTodoToApi(todo *domain.Todo, attachmentInfos []models.AttachmentInfo) *models.Todo { // Takes AttachmentInfo now if todo == nil { return nil } @@ -162,17 +162,18 @@ func mapDomainTodoToApi(todo *domain.Todo) *models.Todo { updatedAt := todo.UpdatedAt return &models.Todo{ - Id: &todoID, - UserId: &userID, - Title: todo.Title, - Description: todo.Description, - Status: models.TodoStatus(todo.Status), - Deadline: todo.Deadline, - TagIds: tagIDs, - Attachments: todo.Attachments, - Subtasks: &apiSubtasks, - CreatedAt: &createdAt, - UpdatedAt: &updatedAt} + Id: &todoID, + UserId: &userID, + Title: todo.Title, + Description: todo.Description, + Status: models.TodoStatus(todo.Status), + Deadline: todo.Deadline, + TagIds: tagIDs, + AttachmentUrl: todo.AttachmentUrl, + Subtasks: &apiSubtasks, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } } func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask { @@ -193,11 +194,11 @@ func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask { UpdatedAt: &updatedAt} } -func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.FileUploadResponse { +func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.AttachmentInfo { if info == nil { return nil } - return &models.FileUploadResponse{ + return &models.AttachmentInfo{ FileId: info.FileID, FileName: info.FileName, FileUrl: info.FileURL, @@ -577,6 +578,8 @@ func (h *ApiHandler) DeleteTagById(w http.ResponseWriter, r *http.Request, tagId // --- Todo Handlers --- +// CreateTodo remains the same + func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) { userID, err := GetUserIDFromContext(r.Context()) if err != nil { @@ -615,10 +618,12 @@ func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) { SendJSONError(w, err, http.StatusInternalServerError, h.logger) return } - apiTodo := mapDomainTodoToApi(todo) + // Newly created todo won't have attachments yet + apiTodo := mapDomainTodoToApi(todo, []models.AttachmentInfo{}) SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger) } +// ListTodos remains the same, doesn't include full attachment details for performance func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) { userID, err := GetUserIDFromContext(r.Context()) if err != nil { @@ -627,7 +632,7 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li } input := service.ListTodosInput{ - Limit: 20, + Limit: 20, // Default limit Offset: 0, } if params.Limit != nil { @@ -641,13 +646,8 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li input.Status = &domainStatus } if params.TagId != nil { - input.TagID = params.TagId - } - if params.DeadlineBefore != nil { - input.DeadlineBefore = params.DeadlineBefore - } - if params.DeadlineAfter != nil { - input.DeadlineAfter = params.DeadlineAfter + domainTagID := uuid.UUID(*params.TagId) + input.TagID = &domainTagID } todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input) @@ -658,28 +658,52 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li apiTodos := make([]models.Todo, len(todos)) for i, todo := range todos { - apiTodos[i] = *mapDomainTodoToApi(&todo) + // For list view, if there is an attachmentUrl, include it as a single-item array + var attachmentInfos []models.AttachmentInfo + if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" { + attachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}} + } else { + attachmentInfos = []models.AttachmentInfo{} + } + mappedTodo := mapDomainTodoToApi(&todo, attachmentInfos) + if mappedTodo != nil { + apiTodos[i] = *mappedTodo + } } SendJSONResponse(w, http.StatusOK, apiTodos, h.logger) } +// GetTodoById updated for single attachmentUrl func (h *ApiHandler) GetTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { - userID, err := GetUserIDFromContext(r.Context()) + ctx := r.Context() + userID, err := GetUserIDFromContext(ctx) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + domainTodoID := uuid.UUID(todoId) + + todo, err := h.services.Todo.GetTodoByID(ctx, domainTodoID, userID) if err != nil { SendJSONError(w, err, http.StatusInternalServerError, h.logger) return } - todo, err := h.services.Todo.GetTodoByID(r.Context(), todoId, userID) - if err != nil { - SendJSONError(w, err, http.StatusInternalServerError, h.logger) - return + // Map attachmentUrl to API model as a single-item array if present + var apiAttachmentInfos []models.AttachmentInfo + if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" { + apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}} + } else { + apiAttachmentInfos = []models.AttachmentInfo{} } - SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger) + apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos) + SendJSONResponse(w, http.StatusOK, apiTodo, h.logger) } +// UpdateTodoById remains the same= + func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { userID, err := GetUserIDFromContext(r.Context()) if err != nil { @@ -711,9 +735,7 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo } input.TagIDs = &domainTagIDs } - if body.Attachments != nil { - input.Attachments = body.Attachments - } + // Note: Attachments are NOT updated via this endpoint in this design todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input) if err != nil { @@ -721,17 +743,28 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo return } - SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger) + // Prepare attachment info for API response using AttachmentUrl field + var apiAttachmentInfos []models.AttachmentInfo + if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" { + apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}} + } else { + apiAttachmentInfos = []models.AttachmentInfo{} + } + + apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos) + SendJSONResponse(w, http.StatusOK, apiTodo, h.logger) } +// DeleteTodoById remains the same (service layer handles attachment deletion) func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { userID, err := GetUserIDFromContext(r.Context()) if err != nil { SendJSONError(w, err, http.StatusInternalServerError, h.logger) return } + domainTodoID := uuid.UUID(todoId) - err = h.services.Todo.DeleteTodo(r.Context(), todoId, userID) + err = h.services.Todo.DeleteTodo(r.Context(), domainTodoID, userID) if err != nil { SendJSONError(w, err, http.StatusInternalServerError, h.logger) return @@ -740,6 +773,72 @@ func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todo w.WriteHeader(http.StatusNoContent) } +// --- Attachment Handlers --- + +func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + ctx := r.Context() + userID, err := GetUserIDFromContext(ctx) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + domainTodoID := uuid.UUID(todoId) + + h.logger.DebugContext(ctx, "Request to delete attachment", "todoId", todoId) + + err = h.services.Todo.DeleteAttachment(ctx, domainTodoID, userID) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + h.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoId) + w.WriteHeader(http.StatusNoContent) +} + +func (h *ApiHandler) UploadOrReplaceTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { + userID, err := GetUserIDFromContext(r.Context()) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + domainTodoID := uuid.UUID(todoId) + + // Parse multipart form (limit to 10 MB) + err = r.ParseMultipartForm(10 << 20) + if err != nil { + SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", err), http.StatusBadRequest, h.logger) + return + } + + file, fileHeader, err := r.FormFile("file") + if err != nil { + SendJSONError(w, fmt.Errorf("missing or invalid file: %w", err), http.StatusBadRequest, h.logger) + return + } + defer file.Close() + + fileName := fileHeader.Filename + fileSize := fileHeader.Size + + todo, err := h.services.Todo.AddAttachment(r.Context(), domainTodoID, userID, fileName, fileSize, file) + if err != nil { + SendJSONError(w, err, http.StatusInternalServerError, h.logger) + return + } + + // Prepare attachment info for API response using AttachmentUrl field + var apiAttachmentInfos []models.AttachmentInfo + if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" { + apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}} + } else { + apiAttachmentInfos = []models.AttachmentInfo{} + } + + apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos) + SendJSONResponse(w, http.StatusOK, apiTodo, h.logger) +} + // --- Subtask Handlers --- func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { @@ -828,53 +927,3 @@ func (h *ApiHandler) DeleteSubtaskById(w http.ResponseWriter, r *http.Request, t w.WriteHeader(http.StatusNoContent) } - -// --- Attachment Handlers --- - -func (h *ApiHandler) UploadTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { - userID, err := GetUserIDFromContext(r.Context()) - if err != nil { - SendJSONError(w, err, http.StatusInternalServerError, h.logger) - return - } - - err = r.ParseMultipartForm(10 << 20) - if err != nil { - SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger) - return - } - - file, handler, err := r.FormFile("file") - if err != nil { - SendJSONError(w, fmt.Errorf("error retrieving the file from form-data: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger) - return - } - defer file.Close() - - fileName := handler.Filename - fileSize := handler.Size - - attachmentInfo, err := h.services.Todo.AddAttachment(r.Context(), todoId, userID, fileName, fileSize, file) - if err != nil { - SendJSONError(w, err, http.StatusInternalServerError, h.logger) - return - } - - SendJSONResponse(w, http.StatusCreated, mapDomainAttachmentInfoToApi(attachmentInfo), h.logger) -} - -func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, attachmentId string) { - userID, err := GetUserIDFromContext(r.Context()) - if err != nil { - SendJSONError(w, err, http.StatusInternalServerError, h.logger) - return - } - - err = h.services.Todo.DeleteAttachment(r.Context(), todoId, userID, attachmentId) - if err != nil { - SendJSONError(w, err, http.StatusInternalServerError, h.logger) - return - } - - w.WriteHeader(http.StatusNoContent) -} diff --git a/backend/internal/api/openapi_types.go b/backend/internal/api/openapi_types.go index f8ea3e3..cc010d3 100644 --- a/backend/internal/api/openapi_types.go +++ b/backend/internal/api/openapi_types.go @@ -42,6 +42,24 @@ const ( ListTodosParamsStatusPending ListTodosParamsStatus = "pending" ) +// AttachmentInfo Metadata about an uploaded attachment. +type AttachmentInfo struct { + // ContentType MIME type of the uploaded file. + ContentType string `json:"contentType"` + + // FileId Unique storage identifier/path for the file (used for deletion). + FileId string `json:"fileId"` + + // FileName Original name of the uploaded file. + FileName string `json:"fileName"` + + // FileUrl URL to access the uploaded file (e.g., a signed GCS URL). + FileUrl string `json:"fileUrl"` + + // Size Size of the uploaded file in bytes. + Size int64 `json:"size"` +} + // CreateSubtaskRequest Data required to create a new Subtask. type CreateSubtaskRequest struct { Description string `json:"description"` @@ -82,23 +100,8 @@ type Error struct { Message string `json:"message"` } -// FileUploadResponse Response after successfully uploading a file. -type FileUploadResponse struct { - // ContentType MIME type of the uploaded file. - ContentType string `json:"contentType"` - - // FileId Unique identifier for the uploaded file. - FileId string `json:"fileId"` - - // FileName Original name of the uploaded file. - FileName string `json:"fileName"` - - // FileUrl URL to access the uploaded file. - FileUrl string `json:"fileUrl"` - - // Size Size of the uploaded file in bytes. - Size int64 `json:"size"` -} +// FileUploadResponse Metadata about an uploaded attachment. +type FileUploadResponse = AttachmentInfo // LoginRequest Data required for logging in via email/password. type LoginRequest struct { @@ -157,35 +160,21 @@ type Tag struct { // Todo Represents a Todo item. type Todo struct { - // Attachments List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints. - Attachments []string `json:"attachments"` - CreatedAt *time.Time `json:"createdAt,omitempty"` - - // Deadline Optional deadline for the Todo item. - Deadline *time.Time `json:"deadline"` - - // Description Optional detailed description of the Todo. - Description *string `json:"description"` - Id *openapi_types.UUID `json:"id,omitempty"` - - // Status Current status of the Todo item. - Status TodoStatus `json:"status"` - - // Subtasks List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints. - Subtasks *[]Subtask `json:"subtasks,omitempty"` - - // TagIds List of IDs of Tags associated with this Todo. - TagIds []openapi_types.UUID `json:"tagIds"` - - // Title The main title or task of the Todo. - Title string `json:"title"` - UpdatedAt *time.Time `json:"updatedAt,omitempty"` - - // UserId The ID of the user who owns this Todo. - UserId *openapi_types.UUID `json:"userId,omitempty"` + // AttachmentUrl Publicly accessible URL of the attached image, if any. + AttachmentUrl *string `json:"attachmentUrl"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + Deadline *time.Time `json:"deadline"` + Description *string `json:"description"` + Id *openapi_types.UUID `json:"id,omitempty"` + Status TodoStatus `json:"status"` + Subtasks *[]Subtask `json:"subtasks,omitempty"` + TagIds []openapi_types.UUID `json:"tagIds"` + Title string `json:"title"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + UserId *openapi_types.UUID `json:"userId,omitempty"` } -// TodoStatus Current status of the Todo item. +// TodoStatus defines model for Todo.Status. type TodoStatus string // UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional. @@ -206,17 +195,13 @@ type UpdateTagRequest struct { Name *string `json:"name,omitempty"` } -// UpdateTodoRequest Data for updating an existing Todo item. All fields are optional for partial updates. +// UpdateTodoRequest Data for updating an existing Todo item. Attachment is managed via dedicated endpoints. type UpdateTodoRequest struct { - // Attachments Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files. - Attachments *[]string `json:"attachments,omitempty"` Deadline *time.Time `json:"deadline"` Description *string `json:"description"` Status *UpdateTodoRequestStatus `json:"status,omitempty"` - - // TagIds Replace the existing list of associated Tag IDs. IDs must belong to the user. - TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"` - Title *string `json:"title,omitempty"` + TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"` + Title *string `json:"title,omitempty"` } // UpdateTodoRequestStatus defines model for UpdateTodoRequest.Status. @@ -259,30 +244,17 @@ type Unauthorized = Error // ListTodosParams defines parameters for ListTodos. type ListTodosParams struct { - // Status Filter Todos by status. Status *ListTodosParamsStatus `form:"status,omitempty" json:"status,omitempty"` - - // TagId Filter Todos by a specific Tag ID. - TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"` - - // DeadlineBefore Filter Todos with deadline before this date/time. - DeadlineBefore *time.Time `form:"deadline_before,omitempty" json:"deadline_before,omitempty"` - - // DeadlineAfter Filter Todos with deadline after this date/time. - DeadlineAfter *time.Time `form:"deadline_after,omitempty" json:"deadline_after,omitempty"` - - // Limit Maximum number of Todos to return. - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - - // Offset Number of Todos to skip for pagination. - Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` } // ListTodosParamsStatus defines parameters for ListTodos. type ListTodosParamsStatus string -// UploadTodoAttachmentMultipartBody defines parameters for UploadTodoAttachment. -type UploadTodoAttachmentMultipartBody struct { +// UploadOrReplaceTodoAttachmentMultipartBody defines parameters for UploadOrReplaceTodoAttachment. +type UploadOrReplaceTodoAttachmentMultipartBody struct { File openapi_types.File `json:"file"` } @@ -304,8 +276,8 @@ type CreateTodoJSONRequestBody = CreateTodoRequest // UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType. type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest -// UploadTodoAttachmentMultipartRequestBody defines body for UploadTodoAttachment for multipart/form-data ContentType. -type UploadTodoAttachmentMultipartRequestBody UploadTodoAttachmentMultipartBody +// UploadOrReplaceTodoAttachmentMultipartRequestBody defines body for UploadOrReplaceTodoAttachment for multipart/form-data ContentType. +type UploadOrReplaceTodoAttachmentMultipartRequestBody UploadOrReplaceTodoAttachmentMultipartBody // CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType. type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest diff --git a/backend/internal/domain/attachment.go b/backend/internal/domain/attachment.go deleted file mode 100644 index a565cdb..0000000 --- a/backend/internal/domain/attachment.go +++ /dev/null @@ -1,10 +0,0 @@ -package domain - -// Attachment info returned by AddAttachment -type AttachmentInfo struct { - FileID string `json:"fileId"` - FileName string `json:"fileName"` - FileURL string `json:"fileUrl"` - ContentType string `json:"contentType"` - Size int64 `json:"size"` -} diff --git a/backend/internal/domain/todo.go b/backend/internal/domain/todo.go index b3bf4d0..b51e42b 100644 --- a/backend/internal/domain/todo.go +++ b/backend/internal/domain/todo.go @@ -16,20 +16,30 @@ const ( ) type Todo struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"userId"` - Title string `json:"title"` - Description *string `json:"description"` // Nullable - Status TodoStatus `json:"status"` - Deadline *time.Time `json:"deadline"` // Nullable - TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching - Tags []Tag `json:"-"` // Can hold full tag objects if needed, loaded separately - Attachments []string `json:"attachments"` // Stores identifiers (e.g., file IDs or URLs) - Subtasks []Subtask `json:"subtasks"` // Populated after fetching - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"userId"` + Title string `json:"title"` + Description *string `json:"description"` // Nullable + Status TodoStatus `json:"status"` + Deadline *time.Time `json:"deadline"` // Nullable + TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching + Tags []Tag `json:"-"` // Loaded separately + AttachmentUrl *string `json:"attachmentUrl"` // Renamed and changed type + Subtasks []Subtask `json:"subtasks"` // Populated after fetching + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } +// Keep AttachmentInfo for upload responses +type AttachmentInfo struct { + FileID string `json:"fileId"` + FileName string `json:"fileName"` + FileURL string `json:"fileUrl"` + ContentType string `json:"contentType"` + Size int64 `json:"size"` +} + +// Helper functions remain the same func NullStringToStringPtr(ns sql.NullString) *string { if ns.Valid { return &ns.String diff --git a/backend/internal/repository/interfaces.go b/backend/internal/repository/interfaces.go index e48e980..21641ec 100644 --- a/backend/internal/repository/interfaces.go +++ b/backend/internal/repository/interfaces.go @@ -40,7 +40,7 @@ type ListTodosParams struct { TagID *uuid.UUID DeadlineBefore *time.Time DeadlineAfter *time.Time - ListParams // Embed pagination + ListParams } type TodoRepository interface { @@ -54,10 +54,8 @@ type TodoRepository interface { RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error) - // Attachment associations (using simple string array) - AddAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error - RemoveAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error - SetAttachments(ctx context.Context, todoID, userID uuid.UUID, attachmentIDs []string) error + // Attachment URL management + UpdateAttachmentURL(ctx context.Context, todoID, userID uuid.UUID, attachmentURL *string) error } type SubtaskRepository interface { diff --git a/backend/internal/repository/sqlc/queries/attachments.sql b/backend/internal/repository/sqlc/queries/attachments.sql deleted file mode 100644 index e2f846b..0000000 --- a/backend/internal/repository/sqlc/queries/attachments.sql +++ /dev/null @@ -1,17 +0,0 @@ --- name: CreateAttachment :one -INSERT INTO attachments (todo_id, user_id, file_name, storage_path, content_type, size) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING *; - --- name: GetAttachmentByID :one -SELECT * FROM attachments -WHERE id = $1 AND user_id = $2 LIMIT 1; - --- name: ListAttachmentsForTodo :many -SELECT * FROM attachments -WHERE todo_id = $1 AND user_id = $2 -ORDER BY uploaded_at ASC; - --- name: DeleteAttachment :exec -DELETE FROM attachments -WHERE id = $1 AND user_id = $2; \ No newline at end of file diff --git a/backend/internal/repository/sqlc/queries/todos.sql b/backend/internal/repository/sqlc/queries/todos.sql index 43034b6..4b05284 100644 --- a/backend/internal/repository/sqlc/queries/todos.sql +++ b/backend/internal/repository/sqlc/queries/todos.sql @@ -1,6 +1,6 @@ -- name: CreateTodo :one -INSERT INTO todos (user_id, title, description, status, deadline) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO todos (user_id, title, description, status, deadline, attachment_url) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetTodoByID :one @@ -11,13 +11,13 @@ WHERE id = $1 AND user_id = $2 LIMIT 1; SELECT t.* FROM todos t LEFT JOIN todo_tags tt ON t.id = tt.todo_id WHERE - t.user_id = sqlc.arg('user_id') -- Use sqlc.arg for required params + t.user_id = sqlc.arg('user_id') AND (sqlc.narg('status_filter')::todo_status IS NULL OR t.status = sqlc.narg('status_filter')) AND (sqlc.narg('tag_id_filter')::uuid IS NULL OR tt.tag_id = sqlc.narg('tag_id_filter')) AND (sqlc.narg('deadline_before_filter')::timestamptz IS NULL OR t.deadline < sqlc.narg('deadline_before_filter')) AND (sqlc.narg('deadline_after_filter')::timestamptz IS NULL OR t.deadline > sqlc.narg('deadline_after_filter')) -GROUP BY t.id -- Still needed due to LEFT JOIN potentially multiplying rows if a todo has multiple tags -ORDER BY t.created_at DESC -- Or your desired order +GROUP BY t.id +ORDER BY t.created_at DESC LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); @@ -25,10 +25,10 @@ OFFSET sqlc.arg('offset'); UPDATE todos SET title = COALESCE(sqlc.narg(title), title), - description = sqlc.narg(description), -- Allow setting description to NULL + description = sqlc.narg(description), status = COALESCE(sqlc.narg(status), status), - deadline = sqlc.narg(deadline), -- Allow setting deadline to NULL - attachments = COALESCE(sqlc.narg(attachments), attachments) + deadline = sqlc.narg(deadline), + attachment_url = COALESCE(sqlc.narg(attachment_url), attachment_url) -- Update attachment_url WHERE id = $1 AND user_id = $2 RETURNING *; @@ -36,12 +36,8 @@ RETURNING *; DELETE FROM todos WHERE id = $1 AND user_id = $2; --- name: AddAttachmentToTodo :exec +-- name: UpdateTodoAttachmentURL :exec +-- Sets or clears the attachment URL for a specific todo UPDATE todos -SET attachments = array_append(attachments, $1) -WHERE id = $2 AND user_id = $3; - --- name: RemoveAttachmentFromTodo :exec -UPDATE todos -SET attachments = array_remove(attachments, $1) +SET attachment_url = $1 -- $1 will be the URL (TEXT) or NULL WHERE id = $2 AND user_id = $3; \ No newline at end of file diff --git a/backend/internal/repository/todo_repo.go b/backend/internal/repository/todo_repo.go index 922ffd5..bde8699 100644 --- a/backend/internal/repository/todo_repo.go +++ b/backend/internal/repository/todo_repo.go @@ -29,15 +29,15 @@ func NewPgxTodoRepository(queries *db.Queries, pool *pgxpool.Pool) TodoRepositor func mapDbTodoToDomain(dbTodo db.Todo) *domain.Todo { return &domain.Todo{ - ID: dbTodo.ID, - UserID: dbTodo.UserID, - Title: dbTodo.Title, - Description: domain.NullStringToStringPtr(dbTodo.Description), - Status: domain.TodoStatus(dbTodo.Status), - Deadline: dbTodo.Deadline, - Attachments: dbTodo.Attachments, - CreatedAt: dbTodo.CreatedAt, - UpdatedAt: dbTodo.UpdatedAt, + ID: dbTodo.ID, + UserID: dbTodo.UserID, + Title: dbTodo.Title, + Description: domain.NullStringToStringPtr(dbTodo.Description), + Status: domain.TodoStatus(dbTodo.Status), + AttachmentUrl: domain.NullStringToStringPtr(dbTodo.AttachmentUrl), + Deadline: dbTodo.Deadline, + CreatedAt: dbTodo.CreatedAt, + UpdatedAt: dbTodo.UpdatedAt, } } @@ -161,7 +161,6 @@ func (r *pgxTodoRepository) Update( Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil}, Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true}, Deadline: updateData.Deadline, - Attachments: updateData.Attachments, } dbTodo, err := r.q.UpdateTodo(ctx, params) @@ -251,61 +250,19 @@ func (r *pgxTodoRepository) GetTags( return tags, nil } -// --- Attachments (String Identifiers in Array) --- - -func (r *pgxTodoRepository) AddAttachment( +func (r *pgxTodoRepository) UpdateAttachmentURL( ctx context.Context, todoID, userID uuid.UUID, - attachmentID string, + attachmentURL *string, ) error { - if _, err := r.GetByID(ctx, todoID, userID); err != nil { - return err - } - if err := r.q.AddAttachmentToTodo(ctx, db.AddAttachmentToTodoParams{ - ArrayAppend: attachmentID, - ID: todoID, - UserID: userID, - }); err != nil { - return fmt.Errorf("failed to add attachment: %w", err) - } - return nil -} - -func (r *pgxTodoRepository) RemoveAttachment( - ctx context.Context, - todoID, userID uuid.UUID, - attachmentID string, -) error { - if _, err := r.GetByID(ctx, todoID, userID); err != nil { - return err - } - if err := r.q.RemoveAttachmentFromTodo(ctx, db.RemoveAttachmentFromTodoParams{ - ArrayRemove: attachmentID, - ID: todoID, - UserID: userID, - }); err != nil { - return fmt.Errorf("failed to remove attachment: %w", err) - } - return nil -} - -func (r *pgxTodoRepository) SetAttachments( - ctx context.Context, - todoID, userID uuid.UUID, - attachmentIDs []string, -) error { - _, err := r.GetByID(ctx, todoID, userID) + query := ` + UPDATE todos + SET attachment_url = $1 + WHERE id = $2 AND user_id = $3 + ` + _, err := r.pool.Exec(ctx, query, attachmentURL, todoID, userID) if err != nil { - return err - } - updateParams := db.UpdateTodoParams{ - ID: todoID, - UserID: userID, - Attachments: attachmentIDs, - } - _, err = r.q.UpdateTodo(ctx, updateParams) - if err != nil { - return fmt.Errorf("failed to set attachments using UpdateTodo: %w", err) + return fmt.Errorf("failed to update attachment URL: %w", err) } return nil } diff --git a/backend/internal/service/gcs_storage.go b/backend/internal/service/gcs_storage.go index d419f33..a8ea4c6 100644 --- a/backend/internal/service/gcs_storage.go +++ b/backend/internal/service/gcs_storage.go @@ -2,12 +2,14 @@ package service import ( "context" + "errors" "fmt" "io" "log/slog" "mime" "path/filepath" "strings" + "time" "cloud.google.com/go/storage" "github.com/Sosokker/todolist-backend/internal/config" @@ -16,79 +18,153 @@ import ( ) type gcsStorageService struct { - bucket string - client *storage.Client - logger *slog.Logger - baseDir string + bucket string + client *storage.Client + logger *slog.Logger + baseDir string + signedURLExpiry time.Duration } func NewGCStorageService(cfg config.GCSStorageConfig, logger *slog.Logger) (FileStorageService, error) { + if cfg.BucketName == "" { + return nil, fmt.Errorf("GCS bucket name is required") + } + opts := []option.ClientOption{} + // Prefer environment variable GOOGLE_APPLICATION_CREDENTIALS + // Only use CredentialsFile from config if it's explicitly set if cfg.CredentialsFile != "" { opts = append(opts, option.WithCredentialsFile(cfg.CredentialsFile)) + logger.Info("Using GCS credentials file specified in config", "path", cfg.CredentialsFile) + } else { + logger.Info("Using default GCS credentials (e.g., GOOGLE_APPLICATION_CREDENTIALS or Application Default Credentials)") } - client, err := storage.NewClient(context.Background(), opts...) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client, err := storage.NewClient(ctx, opts...) if err != nil { return nil, fmt.Errorf("failed to create GCS client: %w", err) } + + // Check bucket existence and permissions + _, err = client.Bucket(cfg.BucketName).Attrs(ctx) + if err != nil { + client.Close() + return nil, fmt.Errorf("failed to access GCS bucket '%s': %w", cfg.BucketName, err) + } + + logger.Info("GCS storage service initialized", "bucket", cfg.BucketName, "baseDir", cfg.BaseDir) + return &gcsStorageService{ - bucket: cfg.BucketName, - client: client, - logger: logger.With("service", "gcsstorage"), - baseDir: cfg.BaseDir, + bucket: cfg.BucketName, + client: client, + logger: logger.With("service", "gcsstorage"), + baseDir: strings.Trim(cfg.BaseDir, "/"), // Ensure no leading/trailing slashes + signedURLExpiry: 168 * time.Hour, // Default signed URL validity }, nil } -func (s *gcsStorageService) GenerateUniqueObjectName(originalFilename string) string { +// GenerateUniqueObjectName creates a unique object path within the bucket's base directory. +// Example: attachments///. +func (s *gcsStorageService) GenerateUniqueObjectName(userID, todoID uuid.UUID, originalFilename string) string { ext := filepath.Ext(originalFilename) - return uuid.NewString() + ext + fileName := uuid.NewString() + ext + objectPath := filepath.Join(s.baseDir, userID.String(), todoID.String(), fileName) + return filepath.ToSlash(objectPath) } - func (s *gcsStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) { - objectName := filepath.Join(s.baseDir, userID.String(), todoID.String(), s.GenerateUniqueObjectName(originalFilename)) - wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctx) - wc.ContentType = mime.TypeByExtension(filepath.Ext(originalFilename)) - wc.ChunkSize = 0 + objectName := s.GenerateUniqueObjectName(userID, todoID, originalFilename) + + ctxUpload, cancel := context.WithTimeout(ctx, 5*time.Minute) // Timeout for upload + defer cancel() + + wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctxUpload) + + // Attempt to determine Content-Type + contentType := mime.TypeByExtension(filepath.Ext(originalFilename)) + if contentType == "" { + contentType = "application/octet-stream" // Default fallback + // Could potentially read first 512 bytes from reader here if it's TeeReader, but might be complex + } + wc.ContentType = contentType + wc.ChunkSize = 0 // Recommended for better performance unless files are huge + + s.logger.DebugContext(ctx, "Uploading file to GCS", "bucket", s.bucket, "object", objectName, "contentType", contentType, "size", size) + written, err := io.Copy(wc, reader) if err != nil { - wc.Close() - s.logger.ErrorContext(ctx, "Failed to upload to GCS", "error", err, "object", objectName) + // Close writer explicitly on error to clean up potential partial uploads + _ = wc.CloseWithError(fmt.Errorf("copy failed: %w", err)) + s.logger.ErrorContext(ctx, "Failed to copy data to GCS", "error", err, "object", objectName) return "", "", fmt.Errorf("failed to upload to GCS: %w", err) } - if written != size { - wc.Close() - s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName) - return "", "", fmt.Errorf("file size mismatch during upload") - } + + // Close the writer to finalize the upload if err := wc.Close(); err != nil { s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName) return "", "", fmt.Errorf("failed to finalize upload: %w", err) } - contentType := wc.ContentType - if contentType == "" { - contentType = "application/octet-stream" + + if written != size { + s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName) + // Optionally delete the potentially corrupted file + _ = s.Delete(context.Background(), objectName) // Use background context for cleanup + return "", "", fmt.Errorf("file size mismatch during upload") } + + s.logger.InfoContext(ctx, "File uploaded successfully to GCS", "object", objectName, "size", written, "contentType", contentType) + // Return the object name (path) as the storage ID return objectName, contentType, nil } func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error { - objectName := filepath.Clean(storageID) - if strings.Contains(objectName, "..") { - s.logger.WarnContext(ctx, "Attempted directory traversal in GCS delete", "storageId", storageID) - return fmt.Errorf("invalid storage ID") + objectName := filepath.Clean(storageID) // storageID is the object path + if strings.Contains(objectName, "..") || !strings.HasPrefix(objectName, s.baseDir+"/") && s.baseDir != "" { + s.logger.WarnContext(ctx, "Attempted invalid delete operation", "storageId", storageID, "baseDir", s.baseDir) + return fmt.Errorf("invalid storage ID for deletion") } + + ctxDelete, cancel := context.WithTimeout(ctx, 30*time.Second) // Timeout for delete + defer cancel() + o := s.client.Bucket(s.bucket).Object(objectName) - err := o.Delete(ctx) - if err != nil && err != storage.ErrObjectNotExist { + err := o.Delete(ctxDelete) + + if err != nil { + if errors.Is(err, storage.ErrObjectNotExist) { + s.logger.WarnContext(ctx, "Attempted to delete non-existent GCS object", "storageId", storageID) + return nil // Treat as success if already deleted + } s.logger.ErrorContext(ctx, "Failed to delete GCS object", "error", err, "storageId", storageID) return fmt.Errorf("could not delete GCS object: %w", err) } - s.logger.InfoContext(ctx, "GCS object deleted", "storageId", storageID) + + s.logger.InfoContext(ctx, "GCS object deleted successfully", "storageId", storageID) return nil } +// GetURL generates a signed URL for accessing the private GCS object. func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) { - objectName := filepath.Clean(storageID) - url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", s.bucket, objectName) + objectName := storageID + if strings.Contains(objectName, "..") || (s.baseDir != "" && !strings.HasPrefix(objectName, s.baseDir+"/")) { + s.logger.WarnContext(ctx, "Attempted invalid GetURL operation", "storageId", storageID, "baseDir", s.baseDir) + return "", fmt.Errorf("invalid storage ID for URL generation") + } + + opts := &storage.SignedURLOptions{ + Scheme: storage.SigningSchemeV4, + Method: "GET", + Expires: time.Now().Add(s.signedURLExpiry), + } + + url, err := s.client.Bucket(s.bucket).SignedURL(objectName, opts) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to generate signed URL", "error", err, "object", objectName) + return "", fmt.Errorf("could not get signed URL for object: %w", err) + } + + s.logger.DebugContext(ctx, "Generated signed URL", "object", objectName, "expiry", opts.Expires) return url, nil } diff --git a/backend/internal/service/interfaces.go b/backend/internal/service/interfaces.go index bbb77d9..697719c 100644 --- a/backend/internal/service/interfaces.go +++ b/backend/internal/service/interfaces.go @@ -78,7 +78,7 @@ type UpdateTodoInput struct { Status *domain.TodoStatus Deadline *time.Time TagIDs *[]uuid.UUID - Attachments *[]string + // Attachments are managed via separate endpoints } type ListTodosInput struct { @@ -92,18 +92,19 @@ type ListTodosInput struct { type TodoService interface { CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error) - GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Includes tags, subtasks - ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) // Includes tags + GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Fetches attachment URL + ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error - // Subtask methods delegate to SubtaskService but check Todo ownership first + // Subtask methods ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error // Attachment methods - AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) // Returns info like ID/URL - DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error + AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.Todo, error) + // Uploads, gets URL, updates Todo, returns updated Todo + DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID) error // Deletes from storage and clears Todo URL } // --- Subtask Service --- @@ -131,9 +132,10 @@ type FileStorageService interface { Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (storageID string, contentType string, err error) // Delete removes the file associated with the given storage identifier. Delete(ctx context.Context, storageID string) error - // GetURL retrieves a publicly accessible URL for the storage ID (optional, might not be needed if files are served differently). + // GetURL retrieves a publicly accessible URL for the storage ID (e.g., signed URL for GCS). GetURL(ctx context.Context, storageID string) (string, error) - GenerateUniqueObjectName(originalFilename string) string + // GenerateUniqueObjectName creates a unique storage path/name for a file. + GenerateUniqueObjectName(userID, todoID uuid.UUID, originalFilename string) string } // ServiceRegistry bundles services diff --git a/backend/internal/service/local_storage.go b/backend/internal/service/local_storage.go deleted file mode 100644 index 838c265..0000000 --- a/backend/internal/service/local_storage.go +++ /dev/null @@ -1,185 +0,0 @@ -package service - -import ( - "context" - "fmt" - "io" - "log/slog" - "mime" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/Sosokker/todolist-backend/internal/config" - "github.com/google/uuid" -) - -type localStorageService struct { - basePath string - logger *slog.Logger -} - -// NewLocalStorageService creates a service for storing files on the local disk. -func NewLocalStorageService(cfg config.LocalStorageConfig, logger *slog.Logger) (FileStorageService, error) { - if cfg.Path == "" { - return nil, fmt.Errorf("local storage path cannot be empty") - } - - // Ensure the base directory exists - err := os.MkdirAll(cfg.Path, 0750) // Use appropriate permissions - if err != nil { - return nil, fmt.Errorf("failed to create local storage directory '%s': %w", cfg.Path, err) - } - - logger.Info("Local file storage initialized", "path", cfg.Path) - return &localStorageService{ - basePath: cfg.Path, - logger: logger.With("service", "localstorage"), - }, nil -} - -// GenerateUniqueObjectName creates a unique path/filename for storage. -// Example: user_uuid/todo_uuid/file_uuid.ext -func (s *localStorageService) GenerateUniqueObjectName(originalFilename string) string { - ext := filepath.Ext(originalFilename) - fileName := uuid.NewString() + ext - return fileName -} - -func (s *localStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) { - // Create a unique filename - uniqueFilename := s.GenerateUniqueObjectName(originalFilename) - - // Create user/todo specific subdirectory structure - subDir := filepath.Join(userID.String(), todoID.String()) - fullDir := filepath.Join(s.basePath, subDir) - if err := os.MkdirAll(fullDir, 0750); err != nil { - s.logger.ErrorContext(ctx, "Failed to create subdirectory for upload", "error", err, "path", fullDir) - return "", "", fmt.Errorf("could not create storage directory: %w", err) - } - - // Define the full path for the file - filePath := filepath.Join(fullDir, uniqueFilename) - storageID := filepath.Join(subDir, uniqueFilename) // Relative path used as ID - - // Create the destination file - dst, err := os.Create(filePath) - if err != nil { - s.logger.ErrorContext(ctx, "Failed to create destination file", "error", err, "path", filePath) - return "", "", fmt.Errorf("could not create file: %w", err) - } - defer dst.Close() - - // Copy the content from the reader to the destination file - written, err := io.Copy(dst, reader) - if err != nil { - // Attempt to clean up partially written file - os.Remove(filePath) - s.logger.ErrorContext(ctx, "Failed to copy file content", "error", err, "path", filePath) - return "", "", fmt.Errorf("could not write file content: %w", err) - } - if written != size { - // Attempt to clean up file if size mismatch (could indicate truncated upload) - os.Remove(filePath) - s.logger.WarnContext(ctx, "File size mismatch during upload", "expected", size, "written", written, "path", filePath) - return "", "", fmt.Errorf("file size mismatch during upload") - } - - // Detect content type - contentType := s.detectContentType(filePath, originalFilename) - - s.logger.InfoContext(ctx, "File uploaded successfully", "storageId", storageID, "originalName", originalFilename, "size", size, "contentType", contentType) - // Return the relative path as the storage identifier - return storageID, contentType, nil -} - -func (s *localStorageService) Delete(ctx context.Context, storageID string) error { - // Prevent directory traversal attacks - cleanStorageID := filepath.Clean(storageID) - if strings.Contains(cleanStorageID, "..") { - s.logger.WarnContext(ctx, "Attempted directory traversal in delete", "storageId", storageID) - return fmt.Errorf("invalid storage ID") - } - - fullPath := filepath.Join(s.basePath, cleanStorageID) - - err := os.Remove(fullPath) - if err != nil { - if os.IsNotExist(err) { - s.logger.WarnContext(ctx, "Attempted to delete non-existent file", "storageId", storageID) - // Consider returning nil here if deleting non-existent is okay - return nil - } - s.logger.ErrorContext(ctx, "Failed to delete file", "error", err, "storageId", storageID) - return fmt.Errorf("could not delete file: %w", err) - } - - s.logger.InfoContext(ctx, "File deleted successfully", "storageId", storageID) - - dir := filepath.Dir(fullPath) - if isEmpty, _ := IsDirEmpty(dir); isEmpty { - os.Remove(dir) - } - dir = filepath.Dir(dir) // Go up one more level - if isEmpty, _ := IsDirEmpty(dir); isEmpty { - os.Remove(dir) - } - - return nil -} - -// GetURL for local storage might just return a path or require a separate file server. -// This implementation returns a placeholder indicating it's not a direct URL. -func (s *localStorageService) GetURL(ctx context.Context, storageID string) (string, error) { - // Local storage doesn't inherently provide a web URL. - // You would typically need a separate static file server pointing to `basePath`. - // For now, return the storageID itself or a placeholder path. - // Example: If you have a file server at /static/uploads mapped to basePath: - // return "/static/uploads/" + filepath.ToSlash(storageID), nil - return fmt.Sprintf("local://%s", storageID), nil // Placeholder indicating local storage -} - -// detectContentType tries to determine the MIME type of the file. -func (s *localStorageService) detectContentType(filePath string, originalFilename string) string { - // First, try based on file extension - ext := filepath.Ext(originalFilename) - mimeType := mime.TypeByExtension(ext) - if mimeType != "" { - return mimeType - } - - // If extension didn't work, try reading the first 512 bytes - file, err := os.Open(filePath) - if err != nil { - s.logger.Warn("Could not open file for content type detection", "error", err, "path", filePath) - return "application/octet-stream" // Default fallback - } - defer file.Close() - - buffer := make([]byte, 512) - n, err := file.Read(buffer) - if err != nil && err != io.EOF { - s.logger.Warn("Could not read file for content type detection", "error", err, "path", filePath) - return "application/octet-stream" - } - - // http.DetectContentType works best with the file beginning - mimeType = http.DetectContentType(buffer[:n]) - return mimeType -} - -func IsDirEmpty(name string) (bool, error) { - f, err := os.Open(name) - if err != nil { - return false, err - } - defer f.Close() - - // Read just one entry. If EOF, directory is empty. - _, err = f.Readdirnames(1) - if err == io.EOF { - return true, nil - } - return false, err // Either not empty or error during read -} diff --git a/backend/internal/service/todo_service.go b/backend/internal/service/todo_service.go index 7f548f2..6ad670c 100644 --- a/backend/internal/service/todo_service.go +++ b/backend/internal/service/todo_service.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "time" "github.com/Sosokker/todolist-backend/internal/domain" "github.com/Sosokker/todolist-backend/internal/repository" @@ -14,8 +15,8 @@ import ( type todoService struct { todoRepo repository.TodoRepository - tagService TagService // Depend on TagService for validation - subtaskService SubtaskService // Depend on SubtaskService + tagService TagService + subtaskService SubtaskService storageService FileStorageService logger *slog.Logger } @@ -56,13 +57,13 @@ func (s *todoService) CreateTodo(ctx context.Context, userID uuid.UUID, input Cr } newTodo := &domain.Todo{ - UserID: userID, - Title: input.Title, - Description: input.Description, - Status: status, - Deadline: input.Deadline, - TagIDs: input.TagIDs, - Attachments: []string{}, + UserID: userID, + Title: input.Title, + Description: input.Description, + Status: status, + Deadline: input.Deadline, + TagIDs: input.TagIDs, + AttachmentUrl: nil, // No attachment on creation } createdTodo, err := s.todoRepo.Create(ctx, newTodo) @@ -114,6 +115,9 @@ func (s *todoService) GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) todo.Subtasks = subtasks } + // Note: todo.Attachments currently holds storage IDs (paths). + // The handler will call GetAttachmentURLs to convert these to full URLs for the API response. + return todo, nil } @@ -160,20 +164,21 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, } updateData := &domain.Todo{ - ID: existingTodo.ID, - UserID: existingTodo.UserID, - Title: existingTodo.Title, - Description: existingTodo.Description, - Status: existingTodo.Status, - Deadline: existingTodo.Deadline, - Attachments: existingTodo.Attachments, + ID: existingTodo.ID, + UserID: existingTodo.UserID, + Title: existingTodo.Title, + Description: existingTodo.Description, + Status: existingTodo.Status, + Deadline: existingTodo.Deadline, + TagIDs: existingTodo.TagIDs, + AttachmentUrl: existingTodo.AttachmentUrl, // Single attachment URL } updated := false if input.Title != nil { - if *input.Title == "" { - return nil, fmt.Errorf("title cannot be empty: %w", domain.ErrValidation) + if err := ValidateTodoTitle(*input.Title); err != nil { + return nil, err } updateData.Title = *input.Title updated = true @@ -203,21 +208,10 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, s.logger.ErrorContext(ctx, "Failed to update tags for todo", "error", err, "todoId", todoID) return nil, domain.ErrInternalServer } - updateData.TagIDs = *input.TagIDs tagsUpdated = true } - attachmentsUpdated := false - if input.Attachments != nil { - err = s.todoRepo.SetAttachments(ctx, todoID, userID, *input.Attachments) - if err != nil { - s.logger.ErrorContext(ctx, "Failed to update attachments list for todo", "error", err, "todoId", todoID) - return nil, domain.ErrInternalServer - } - updateData.Attachments = *input.Attachments - attachmentsUpdated = true - } - + // Update the core fields if anything changed var updatedRepoTodo *domain.Todo if updated { updatedRepoTodo, err = s.todoRepo.Update(ctx, todoID, userID, updateData) @@ -226,42 +220,57 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, return nil, domain.ErrInternalServer } } else { - updatedRepoTodo = updateData + // If only tags were updated, we still need the latest full todo data + updatedRepoTodo = existingTodo } - if !updated && (tagsUpdated || attachmentsUpdated) { - updatedRepoTodo.Title = existingTodo.Title - updatedRepoTodo.Description = existingTodo.Description + // If tags were updated, reload the full todo to get the updated TagIDs array + if tagsUpdated { + reloadedTodo, reloadErr := s.GetTodoByID(ctx, todoID, userID) + if reloadErr != nil { + s.logger.WarnContext(ctx, "Failed to reload todo after tag update, returning potentially stale data", "error", reloadErr, "todoId", todoID) + // Return the todo data we have, even if tags might be slightly out of sync temporarily + if updatedRepoTodo != nil { + updatedRepoTodo.TagIDs = *input.TagIDs // Manually set IDs based on input + return updatedRepoTodo, nil + } + return existingTodo, nil // Fallback + } + return reloadedTodo, nil } - finalTodo, err := s.GetTodoByID(ctx, todoID, userID) - if err != nil { - s.logger.WarnContext(ctx, "Failed to reload todo after update, returning partial data", "error", err, "todoId", todoID) - return updatedRepoTodo, nil - } - - return finalTodo, nil + return updatedRepoTodo, nil // Return the result from repo Update or existing if only tags changed } func (s *todoService) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error { existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID) if err != nil { - return err + if errors.Is(err, domain.ErrNotFound) { + return nil // Already deleted or doesn't exist/belong to user + } + return err // Internal error } - attachmentIDsToDelete := existingTodo.Attachments - + // Delete the Todo record from the database first err = s.todoRepo.Delete(ctx, todoID, userID) if err != nil { s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID) return domain.ErrInternalServer } - for _, storageID := range attachmentIDsToDelete { - if err := s.storageService.Delete(ctx, storageID); err != nil { + // If there is an attachment, attempt to delete it from storage (best effort) + if existingTodo.AttachmentUrl != nil { + storageID := *existingTodo.AttachmentUrl + deleteCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + if err := s.storageService.Delete(deleteCtx, storageID); err != nil { s.logger.WarnContext(ctx, "Failed to delete attachment file during todo deletion", "error", err, "storageId", storageID, "todoId", todoID) + } else { + s.logger.InfoContext(ctx, "Deleted attachment file during todo deletion", "storageId", storageID, "todoId", todoID) } } + + s.logger.InfoContext(ctx, "Successfully deleted todo and attempted attachment cleanup", "todoId", todoID, "userId", userID) return nil } @@ -284,72 +293,67 @@ func (s *todoService) CreateSubtask(ctx context.Context, todoID, userID uuid.UUI } func (s *todoService) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) { + // Check if parent todo belongs to user first (optional but safer) + _, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return nil, err + } + // Subtask service's GetByID/Update methods inherently check ownership via JOINs return s.subtaskService.Update(ctx, subtaskID, userID, input) } func (s *todoService) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error { + // Check if parent todo belongs to user first (optional but safer) + _, err := s.todoRepo.GetByID(ctx, todoID, userID) + if err != nil { + return err + } + // Subtask service's Delete method inherently checks ownership via JOINs return s.subtaskService.Delete(ctx, subtaskID, userID) } -// --- Attachment Methods --- (Implementation depends on FileStorageService) +// --- Attachment Methods (Simplified) --- -func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, originalFilename string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) { +func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.Todo, error) { _, err := s.todoRepo.GetByID(ctx, todoID, userID) if err != nil { return nil, err } - storageID, contentType, err := s.storageService.Upload(ctx, userID, todoID, originalFilename, fileContent, fileSize) + storageID, _, err := s.storageService.Upload(ctx, userID, todoID, fileName, fileContent, fileSize) if err != nil { - s.logger.ErrorContext(ctx, "Failed to upload attachment to storage", "error", err, "todoId", todoID, "fileName", originalFilename) - return nil, domain.ErrInternalServer + s.logger.ErrorContext(ctx, "Failed to upload attachment", "error", err, "todoId", todoID) + return nil, err } - if err = s.todoRepo.AddAttachment(ctx, todoID, userID, storageID); err != nil { - s.logger.ErrorContext(ctx, "Failed to add attachment storage ID to todo", "error", err, "todoId", todoID, "storageId", storageID) - if delErr := s.storageService.Delete(context.Background(), storageID); delErr != nil { - s.logger.ErrorContext(ctx, "Failed to delete orphaned attachment file after DB error", "deleteError", delErr, "storageId", storageID) - } - return nil, domain.ErrInternalServer + // Construct the public URL for the uploaded file in GCS + publicURL, err := s.storageService.GetURL(ctx, storageID) + if err != nil { + s.logger.ErrorContext(ctx, "Failed to generate public URL for attachment", "error", err, "todoId", todoID, "storageId", storageID) + return nil, err } - fileURL, _ := s.storageService.GetURL(ctx, storageID) + if err := s.todoRepo.UpdateAttachmentURL(ctx, todoID, userID, &publicURL); err != nil { + s.logger.ErrorContext(ctx, "Failed to update attachment URL in repo", "error", err, "todoId", todoID) + return nil, err + } - return &domain.AttachmentInfo{ - FileID: storageID, - FileName: originalFilename, - FileURL: fileURL, - ContentType: contentType, - Size: fileSize, - }, nil + s.logger.InfoContext(ctx, "Attachment added successfully", "todoId", todoID, "storageId", storageID) + + return s.GetTodoByID(ctx, todoID, userID) } -func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, storageID string) error { - todo, err := s.todoRepo.GetByID(ctx, todoID, userID) +func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID) error { + _, err := s.todoRepo.GetByID(ctx, todoID, userID) if err != nil { return err } - found := false - for _, att := range todo.Attachments { - if att == storageID { - found = true - break - } - } - if !found { - return fmt.Errorf("attachment '%s' not found on todo %s: %w", storageID, todoID, domain.ErrNotFound) - } - - if err = s.todoRepo.RemoveAttachment(ctx, todoID, userID, storageID); err != nil { - s.logger.ErrorContext(ctx, "Failed to remove attachment ID from todo", "error", err, "todoId", todoID, "storageId", storageID) - return domain.ErrInternalServer - } - - if err = s.storageService.Delete(ctx, storageID); err != nil { - s.logger.ErrorContext(ctx, "Failed to delete attachment file from storage after removing DB ref", "error", err, "storageId", storageID) - return nil + if err := s.todoRepo.UpdateAttachmentURL(ctx, todoID, userID, nil); err != nil { + s.logger.ErrorContext(ctx, "Failed to update attachment URL in repo", "error", err, "todoId", todoID) + return err } + s.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoID) return nil } diff --git a/backend/migrations/000002_add_single_attachment_url.down.sql b/backend/migrations/000002_add_single_attachment_url.down.sql new file mode 100644 index 0000000..1a65dbb --- /dev/null +++ b/backend/migrations/000002_add_single_attachment_url.down.sql @@ -0,0 +1,20 @@ +-- backend/migrations/000002_add_single_attachment_url.down.sql +-- Re-add the old array column and table (might lose data) +ALTER TABLE todos +ADD COLUMN attachments TEXT[] NOT NULL DEFAULT '{}'; + +CREATE TABLE attachments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + storage_path VARCHAR(512) NOT NULL, + content_type VARCHAR(100) NOT NULL, + size BIGINT NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_attachments_todo_id ON attachments(todo_id); + +-- Drop the new single URL column +ALTER TABLE todos +DROP COLUMN IF EXISTS attachment_url; \ No newline at end of file diff --git a/backend/migrations/000002_add_single_attachment_url.up.sql b/backend/migrations/000002_add_single_attachment_url.up.sql new file mode 100644 index 0000000..468f6fd --- /dev/null +++ b/backend/migrations/000002_add_single_attachment_url.up.sql @@ -0,0 +1,14 @@ +-- backend/migrations/000002_add_single_attachment_url.up.sql +ALTER TABLE todos +ADD COLUMN attachment_url TEXT NULL; + +-- Optional: Add a comment for clarity +COMMENT ON COLUMN todos.attachment_url IS 'Publicly accessible URL for the single image attachment'; + +-- Drop the old attachments array column and the separate attachments table +ALTER TABLE todos DROP COLUMN IF EXISTS attachments; +DROP TABLE IF EXISTS attachments; -- Cascade should handle FKs if any existed, but we assume it's clean + +-- NOTE: No data migration from TEXT[] to TEXT is included here for simplicity. +-- In a real scenario, you might add logic here to migrate the first element +-- of the old array if it represented a URL, but that depends heavily on previous data. \ No newline at end of file diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 037df03..c998bd5 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1,9 +1,9 @@ openapi: 3.0.3 info: title: Todolist API - version: 1.2.0 # Incremented version + version: 1.3.0 # Incremented version description: | - API for managing Todo items, including CRUD operations, subtasks, deadlines, attachments, and user-defined Tags. + API for managing Todo items, including CRUD operations, subtasks, deadlines, attachments (stored in GCS), and user-defined Tags. Supports user authentication via email/password (JWT) and Google OAuth. Designed for use with oapi-codegen and Chi. @@ -11,26 +11,22 @@ info: **Note on Tag Deletion:** Deleting a Tag will typically remove its association from any Todo items currently using it. servers: - # The base path for all API routes defined below. - # oapi-codegen will use this when setting up routes with HandlerFromMux. - url: /api/v1 description: API version 1 components: - # Security Schemes used by the API securitySchemes: - BearerAuth: # Used by API clients (non-browser) + BearerAuth: type: http scheme: bearer bearerFormat: JWT description: JWT authentication token provided in the Authorization header. - CookieAuth: # Used by the web application (browser) + CookieAuth: type: apiKey in: cookie - name: jwt_token # Name needs to match config.AppConfig.CookieName + name: jwt_token description: JWT authentication token provided via an HTTP-only cookie. - # Reusable Schemas schemas: # --- User Schemas --- User: @@ -80,7 +76,7 @@ components: password: type: string minLength: 6 - writeOnly: true # Password should not appear in responses + writeOnly: true required: - username - email @@ -123,9 +119,6 @@ components: type: string minLength: 3 maxLength: 50 - # Add other updatable fields like email if needed (consider verification flow) - # Password updates might warrant a separate endpoint /users/me/password - # No required fields, allows partial updates # --- Tag Schemas --- Tag: @@ -146,7 +139,7 @@ components: description: Name of the tag (e.g., "Work", "Personal"). Must be unique per user. color: type: string - format: hexcolor # Custom format hint, e.g., #FF5733 + format: hexcolor nullable: true description: Optional color associated with the tag. icon: @@ -210,71 +203,68 @@ components: maxLength: 30 description: New icon identifier. + # --- Attachment Info Schema --- + AttachmentInfo: + type: object + description: Metadata about an uploaded attachment. + properties: + fileId: + type: string + description: Unique storage identifier/path for the file (used for deletion). + fileName: + type: string + description: Original name of the uploaded file. + fileUrl: + type: string + format: url + description: URL to access the uploaded file (e.g., a signed GCS URL). + contentType: + type: string + description: MIME type of the uploaded file. + size: + type: integer + format: int64 + description: Size of the uploaded file in bytes. + required: + - fileId + - fileName + - fileUrl + - contentType + - size + # --- Todo Schemas --- Todo: type: object description: Represents a Todo item. properties: - id: - type: string - format: uuid - readOnly: true - userId: - type: string - format: uuid - readOnly: true - description: The ID of the user who owns this Todo. - title: - type: string - description: The main title or task of the Todo. - description: - type: string - nullable: true - description: Optional detailed description of the Todo. - status: - type: string - enum: [pending, in-progress, completed] - default: pending - description: Current status of the Todo item. - deadline: - type: string - format: date-time - nullable: true - description: Optional deadline for the Todo item. - tagIds: # <-- Added + id: { type: string, format: uuid, readOnly: true } + userId: { type: string, format: uuid, readOnly: true } + title: { type: string } + description: { type: string, nullable: true } + status: { type: string, enum: [pending, in-progress, completed], default: pending } + deadline: { type: string, format: date-time, nullable: true } + tagIds: type: array - items: - type: string - format: uuid - description: List of IDs of Tags associated with this Todo. - default: [] - attachments: - type: array - items: - type: string - description: List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints. + items: { type: string, format: uuid } default: [] + attachmentUrl: # <-- Changed from attachments array + type: string + format: url + nullable: true + description: Publicly accessible URL of the attached image, if any. subtasks: type: array - items: - $ref: '#/components/schemas/Subtask' - description: List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints. - readOnly: true # Subtasks typically managed via their own endpoints - createdAt: - type: string - format: date-time - readOnly: true - updatedAt: - type: string - format: date-time + items: { $ref: '#/components/schemas/Subtask' } readOnly: true + default: [] + createdAt: { type: string, format: date-time, readOnly: true } + updatedAt: { type: string, format: date-time, readOnly: true } required: - id - userId - title - status - - tagIds # <-- Added - - attachments + - tagIds - createdAt - updatedAt @@ -296,7 +286,7 @@ components: type: string format: date-time nullable: true - tagIds: # <-- Added + tagIds: type: array items: type: string @@ -308,33 +298,16 @@ components: UpdateTodoRequest: type: object - description: Data for updating an existing Todo item. All fields are optional for partial updates. + description: Data for updating an existing Todo item. Attachment is managed via dedicated endpoints. properties: - title: - type: string - minLength: 1 - description: - type: string - nullable: true - status: - type: string - enum: [pending, in-progress, completed] - deadline: - type: string - format: date-time - nullable: true - tagIds: # <-- Added + title: { type: string, minLength: 1 } + description: { type: string, nullable: true } + status: { type: string, enum: [pending, in-progress, completed] } + deadline: { type: string, format: date-time, nullable: true } + tagIds: type: array - items: - type: string - format: uuid - description: Replace the existing list of associated Tag IDs. IDs must belong to the user. - attachments: # Allow updating the list of attachments explicitly - type: array - items: - type: string - description: Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files. - + items: { type: string, format: uuid } + # --- Subtask Schemas --- Subtask: type: object @@ -392,34 +365,9 @@ components: completed: type: boolean - # --- File Upload Schemas --- - FileUploadResponse: - type: object - description: Response after successfully uploading a file. - properties: - fileId: - type: string - description: Unique identifier for the uploaded file. - fileName: - type: string - description: Original name of the uploaded file. - fileUrl: - type: string - format: url - description: URL to access the uploaded file. - contentType: - type: string - description: MIME type of the uploaded file. - size: - type: integer - format: int64 - description: Size of the uploaded file in bytes. - required: - - fileId - - fileName - - fileUrl - - contentType - - size + # --- File Upload Response Schema --- + FileUploadResponse: # This is the same as AttachmentInfo, could reuse definition with $ref + $ref: '#/components/schemas/AttachmentInfo' # --- Error Schema --- Error: @@ -437,7 +385,6 @@ components: - code - message - # Reusable Responses responses: BadRequest: description: Invalid input (e.g., validation error, missing fields, invalid tag ID). @@ -458,7 +405,7 @@ components: schema: $ref: "#/components/schemas/Error" NotFound: - description: The requested resource (e.g., Todo, Tag, Subtask) was not found. + description: The requested resource (e.g., Todo, Tag, Subtask, Attachment) was not found. content: application/json: schema: @@ -476,13 +423,10 @@ components: schema: $ref: "#/components/schemas/Error" -# Security Requirement applied globally or per-operation -# Most endpoints require either Bearer or Cookie auth. security: - BearerAuth: [] - CookieAuth: [] -# API Path Definitions paths: # --- Authentication Endpoints --- /auth/signup: @@ -490,7 +434,7 @@ paths: summary: Register a new user via email/password (API). operationId: signupUserApi tags: [Auth] - security: [] # No auth required to sign up + security: [] requestBody: required: true description: User details for registration. @@ -508,7 +452,7 @@ paths: "400": $ref: "#/components/responses/BadRequest" "409": - $ref: "#/components/responses/Conflict" # e.g., Email or Username already exists + $ref: "#/components/responses/Conflict" "500": $ref: "#/components/responses/InternalServerError" @@ -518,7 +462,7 @@ paths: description: Authenticates a user and returns a JWT access token in the response body for API clients. For browser clients, this endpoint typically also sets an HTTP-only cookie containing the JWT. operationId: loginUserApi tags: [Auth] - security: [] # No auth required to log in + security: [] requestBody: required: true description: User credentials for login. @@ -534,24 +478,23 @@ paths: schema: $ref: "#/components/schemas/LoginResponse" headers: - Set-Cookie: # Indicate that a cookie might be set for browser clients + Set-Cookie: schema: type: string description: Contains the JWT authentication cookie (e.g., `jwt_token=...; HttpOnly; Secure; Path=/; SameSite=Lax`) "400": $ref: "#/components/responses/BadRequest" "401": - $ref: "#/components/responses/Unauthorized" # Invalid credentials + $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalServerError" - /auth/logout: # Often useful to have an explicit logout + /auth/logout: post: summary: Log out the current user. description: Invalidates the current session (e.g., clears the authentication cookie). operationId: logoutUser tags: [Auth] - # Requires authentication to know *who* is logging out to clear their session/cookie security: - BearerAuth: [] - CookieAuth: [] @@ -559,12 +502,12 @@ paths: "204": description: Logout successful. No content returned. headers: - Set-Cookie: # Indicate that the cookie is being cleared + Set-Cookie: schema: type: string description: Clears the JWT authentication cookie (e.g., `jwt_token=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax`) "401": - $ref: "#/components/responses/Unauthorized" # If not logged in initially + $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalServerError" @@ -574,7 +517,7 @@ paths: description: Redirects the user's browser to Google's authentication page. Not a typical REST endpoint, part of the web flow. operationId: initiateGoogleLogin tags: [Auth] - security: [] # No API auth needed to start the flow + security: [] responses: "302": description: Redirect to Google's OAuth consent screen. The 'Location' header contains the redirect URL. @@ -597,20 +540,7 @@ paths: description: Google redirects the user here after authentication. The server exchanges the received code for tokens, finds/creates the user, generates a JWT, sets the auth cookie, and redirects the user (e.g., to the web app dashboard). operationId: handleGoogleCallback tags: [Auth] - security: [] # No API auth needed, Google provides auth code via query param - # parameters: - # - name: code - # in: query - # required: true - # schema: - # type: string - # description: Authorization code provided by Google. - # - name: state - # in: query - # required: false # Recommended for security (CSRF protection) - # schema: - # type: string - # description: Opaque value used to maintain state between the request and callback. + security: [] responses: "302": description: Authentication successful. Redirects the user to the frontend application (e.g., '/dashboard'). Sets auth cookie. @@ -680,15 +610,15 @@ paths: schema: $ref: "#/components/schemas/User" "400": - $ref: "#/components/responses/BadRequest" # Validation error + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "409": - $ref: "#/components/responses/Conflict" # e.g. Username already taken + $ref: "#/components/responses/Conflict" "500": $ref: "#/components/responses/InternalServerError" - # --- Tag Endpoints --- <-- New Section + # --- Tag Endpoints --- /tags: get: summary: List all tags created by the current user. @@ -732,11 +662,11 @@ paths: schema: $ref: '#/components/schemas/Tag' "400": - $ref: "#/components/responses/BadRequest" # Validation error + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "409": - $ref: "#/components/responses/Conflict" # Tag name already exists for this user + $ref: "#/components/responses/Conflict" "500": $ref: "#/components/responses/InternalServerError" @@ -766,7 +696,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User does not own this tag + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": @@ -793,15 +723,15 @@ paths: schema: $ref: '#/components/schemas/Tag' "400": - $ref: "#/components/responses/BadRequest" # Validation error + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User does not own this tag + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "409": - $ref: "#/components/responses/Conflict" # New tag name already exists for this user + $ref: "#/components/responses/Conflict" "500": $ref: "#/components/responses/InternalServerError" delete: @@ -818,13 +748,12 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User does not own this tag + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalServerError" - # --- Todo Endpoints --- /todos: get: @@ -835,59 +764,14 @@ paths: - BearerAuth: [] - CookieAuth: [] parameters: - - name: status - in: query - required: false - schema: - type: string - enum: [pending, in-progress, completed] - description: Filter Todos by status. - - name: tagId # <-- Added filter parameter - in: query - required: false - schema: - type: string - format: uuid - description: Filter Todos by a specific Tag ID. - - name: deadline_before - in: query - required: false - schema: - type: string - format: date-time - description: Filter Todos with deadline before this date/time. - - name: deadline_after - in: query - required: false - schema: - type: string - format: date-time - description: Filter Todos with deadline after this date/time. - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - default: 20 - description: Maximum number of Todos to return. - - name: offset - in: query - required: false - schema: - type: integer - minimum: 0 - default: 0 - description: Number of Todos to skip for pagination. + - { name: status, in: query, required: false, schema: { type: string, enum: [pending, in-progress, completed] } } + - { name: tagId, in: query, required: false, schema: { type: string, format: uuid } } + - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, default: 20 } } + - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } } responses: "200": - description: A list of Todo items matching the criteria. - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Todo" + description: A list of Todo items. + content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Todo" } } } } "401": $ref: "#/components/responses/Unauthorized" "500": @@ -896,57 +780,35 @@ paths: summary: Create a new Todo item. operationId: createTodo tags: [Todos] - security: - - BearerAuth: [] - - CookieAuth: [] requestBody: required: true - description: Todo item details to create, optionally including Tag IDs. - content: - application/json: - schema: - $ref: "#/components/schemas/CreateTodoRequest" # Now includes tagIds + content: { application/json: { schema: { $ref: "#/components/schemas/CreateTodoRequest" } } } responses: "201": - description: Todo item created successfully. Returns the new Todo. - content: - application/json: - schema: - $ref: "#/components/schemas/Todo" + description: Todo item created successfully. + content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } } "400": - $ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalServerError" /todos/{todoId}: - parameters: # Parameter applicable to all methods for this path - - name: todoId - in: path - required: true - schema: - type: string - format: uuid - description: ID of the Todo item. + parameters: + - { name: todoId, in: path, required: true, schema: { type: string, format: uuid } } get: summary: Get a specific Todo item by ID. operationId: getTodoById tags: [Todos] - security: - - BearerAuth: [] - - CookieAuth: [] responses: "200": description: The requested Todo item. - content: - application/json: - schema: - $ref: "#/components/schemas/Todo" # Now includes tagIds + content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } } "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User doesn't own this Todo + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": @@ -955,29 +817,19 @@ paths: summary: Update a specific Todo item by ID. operationId: updateTodoById tags: [Todos] - security: - - BearerAuth: [] - - CookieAuth: [] requestBody: required: true - description: Fields of the Todo item to update, potentially including the list of Tag IDs. - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateTodoRequest" # Now includes tagIds + content: { application/json: { schema: { $ref: "#/components/schemas/UpdateTodoRequest" } } } responses: "200": - description: Todo item updated successfully. Returns the updated Todo. - content: - application/json: - schema: - $ref: "#/components/schemas/Todo" + description: Todo item updated successfully. + content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } } "400": - $ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User doesn't own this Todo + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": @@ -995,7 +847,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User doesn't own this Todo + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": @@ -1004,62 +856,44 @@ paths: # --- Attachment Endpoints --- /todos/{todoId}/attachments: parameters: - - name: todoId - in: path - required: true - schema: - type: string - format: uuid - description: ID of the Todo item to attach the file to. + - { name: todoId, in: path, required: true, schema: { type: string, format: uuid } } post: - summary: Upload a file and attach it to a Todo item. - operationId: uploadTodoAttachment + summary: Upload or replace the image attachment for a Todo item. + operationId: uploadOrReplaceTodoAttachment # Renamed for clarity tags: [Attachments, Todos] - security: - - BearerAuth: [] - - CookieAuth: [] requestBody: required: true - description: The file to upload. + description: The image file to upload. content: multipart/form-data: schema: type: object - properties: - file: # Name of the form field for the file - type: string - format: binary - required: - - file - # You might add examples or encoding details here if needed + properties: { file: { type: string, format: binary } } + required: [file] responses: - "201": - description: File uploaded and attached successfully. Returns file details. The Todo's `attachments` array is updated server-side. - content: - application/json: - schema: - $ref: '#/components/schemas/FileUploadResponse' - "400": - $ref: "#/components/responses/BadRequest" # e.g., No file, size limit exceeded, invalid file type - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" # User doesn't own this Todo - "404": - $ref: "#/components/responses/NotFound" # Todo not found - "500": - $ref: "#/components/responses/InternalServerError" # File storage error, etc. + "201": # Use 201 Created (or 200 OK if replacing) + description: Image uploaded/replaced successfully. Returns file details. + content: { application/json: { schema: { $ref: '#/components/schemas/FileUploadResponse' } } } # Reusing this schema + "400": { $ref: "#/components/responses/BadRequest" } # Invalid file type, size limit etc. + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } # Todo not found + "500": { $ref: "#/components/responses/InternalServerError" } + delete: + summary: Delete the image attachment from a Todo item. + operationId: deleteTodoAttachment # Reused name is fine + tags: [Attachments, Todos] + responses: + "204": { description: Attachment deleted successfully. } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } # Todo or attachment not found + "500": { $ref: "#/components/responses/InternalServerError" } # --- Subtask Endpoints --- /todos/{todoId}/subtasks: parameters: - - name: todoId - in: path - required: true - schema: - type: string - format: uuid - description: ID of the parent Todo item. + - { name: todoId, in: path, required: true, schema: { type: string, format: uuid } } get: summary: List all subtasks for a specific Todo item. operationId: listSubtasksForTodo @@ -1079,9 +913,9 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + $ref: "#/components/responses/Forbidden" "404": - $ref: "#/components/responses/NotFound" # Parent Todo not found + $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalServerError" post: @@ -1110,9 +944,9 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + $ref: "#/components/responses/Forbidden" "404": - $ref: "#/components/responses/NotFound" # Parent Todo not found + $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalServerError" @@ -1158,9 +992,9 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + $ref: "#/components/responses/Forbidden" "404": - $ref: "#/components/responses/NotFound" # Todo or Subtask not found + $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalServerError" delete: @@ -1176,8 +1010,8 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "403": - $ref: "#/components/responses/Forbidden" # User doesn't own parent Todo + $ref: "#/components/responses/Forbidden" "404": - $ref: "#/components/responses/NotFound" # Todo or Subtask not found + $ref: "#/components/responses/NotFound" "500": - $ref: "#/components/responses/InternalServerError" \ No newline at end of file + $ref: "#/components/responses/InternalServerError" diff --git a/frontend/components/icons.tsx b/frontend/components/icons.tsx index 696d6ef..20fb499 100644 --- a/frontend/components/icons.tsx +++ b/frontend/components/icons.tsx @@ -100,4 +100,5 @@ export const Icons = { loader: IconLoader, circle: IconCircle, moreVertical: IconDotsVertical, + file: IconFile, }; diff --git a/frontend/components/navbar.tsx b/frontend/components/navbar.tsx index 29a503b..b9e4b94 100644 --- a/frontend/components/navbar.tsx +++ b/frontend/components/navbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +// import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; @@ -15,45 +15,45 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Icons } from "@/components/icons"; -import { Badge } from "@/components/ui/badge"; +// import { Badge } from "@/components/ui/badge"; export function Navbar() { const pathname = usePathname(); const { user, logout } = useAuth(); - const [notificationCount, setNotificationCount] = useState(3); + // const [notificationCount, setNotificationCount] = useState(3); - const notifications = [ - { - id: 1, - title: "New task assigned", - description: "You have been assigned a new task", - time: "5 minutes ago", - read: false, - }, - { - id: 2, - title: "Task completed", - description: "Your task 'Update documentation' has been completed", - time: "1 hour ago", - read: false, - }, - { - id: 3, - title: "Meeting reminder", - description: "Team meeting starts in 30 minutes", - time: "2 hours ago", - read: false, - }, - ]; + // const notifications = [ + // { + // id: 1, + // title: "New task assigned", + // description: "You have been assigned a new task", + // time: "5 minutes ago", + // read: false, + // }, + // { + // id: 2, + // title: "Task completed", + // description: "Your task 'Update documentation' has been completed", + // time: "1 hour ago", + // read: false, + // }, + // { + // id: 3, + // title: "Meeting reminder", + // description: "Team meeting starts in 30 minutes", + // time: "2 hours ago", + // read: false, + // }, + // ]; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const markAsRead = (id: number) => { - setNotificationCount(Math.max(0, notificationCount - 1)); - }; + // const markAsRead = (id: number) => { + // setNotificationCount(Math.max(0, notificationCount - 1)); + // }; - const markAllAsRead = () => { - setNotificationCount(0); - }; + // const markAllAsRead = () => { + // setNotificationCount(0); + // }; return (
@@ -110,7 +110,7 @@ export function Navbar() {
{/* Notifications Dropdown */} - + {/* - - - setIsViewDialogOpen(true)} - > - - View details - - setIsEditDialogOpen(true)} - > - - Edit - - - - Delete - - - - )} -
- {/* Actions and deadline on a new line */} -
- {todo.deadline && ( - - - {formatDate(todo.deadline)} - - )} -
- - + {/* Optional Cover Image */} + {coverImage && ( +
+ Todo Attachment +
+ )} + {/* Title and Menu */} +
+ - {todo.description} - - {/* Tags and indicators */} -
- {todoTags.map((tag) => ( - - {tag.name} - - ))} - {hasSubtasks && ( - - - {completedSubtasks}/{todo.subtasks.length} - - )} - {hasAttachments && ( - - - {todo.attachments.length} - - )} - {hasImage && ( - - - - )} -
- {/* Bottom row: created date and status toggle */} -
- - {todo.createdAt ? formatDate(todo.createdAt) : ""} - - -
+ + + + e.stopPropagation()} + > + setIsViewDialogOpen(true)}> + View + + setIsEditDialogOpen(true)}> + Edit + + + Delete + + + + )} +
+ {/* Description (optional, truncated) */} + {todo.description && ( +

+ {todo.description} +

+ )} + {/* Badges and Indicators */} +
+ {todoTags.map((tag) => ( + + {tag.name} + + ))} + {hasSubtasks && ( + + + {completedSubtasks}/{todo.subtasks.length} + + )} + {hasAttachments && ( + + 1 + + )} + {todo.deadline && ( + + + {formatDate(todo.deadline)} + + )} +
+ {/* Bottom Row: Created Date & Status Toggle */} +
+ + {todo.createdAt ? formatDate(todo.createdAt) : ""} + +
@@ -261,17 +258,17 @@ export function TodoCard({ Edit Todo - Make changes to your task and save when you're done + Make changes to your task, add attachments, and save. { - onUpdate(updatedTodo); + onSubmit={async (updatedTodoData) => { + await onUpdate(updatedTodoData); setIsEditDialogOpen(false); - toast.success("Todo updated successfully"); }} + onAttachmentsChanged={onAttachmentsChanged} /> @@ -283,7 +280,7 @@ export function TodoCard({
{todo.status.replace("-", " ")} @@ -324,10 +315,11 @@ export function TodoCard({ Created {formatDate(todo.createdAt)} - {hasImage && ( -
+ {/* Cover Image */} + {coverImage && ( +
{todo.title}
)} -
+ {/* Content Section */} +
+ {/* Description */} {todo.description && (
{todo.description}
)} + {/* Tags */} {todoTags.length > 0 && (
-

Tags

+

+ TAGS +

{todoTags.map((tag) => (
)} - {hasAttachments && ( -
-

Attachments

-
- {todo.attachments.map((a, i) => ( -
- - {a} -
- ))} -
-
- )} + {/* Subtasks */} {hasSubtasks && (
-

- Subtasks ({completedSubtasks}/{todo.subtasks.length}) +

+ SUBTASKS ({completedSubtasks}/{todo.subtasks.length})

    {todo.subtasks.map((subtask) => ( @@ -413,8 +395,42 @@ export function TodoCard({
)} + {/* Attachments */} + {hasAttachments && ( +
+

+ ATTACHMENT +

+
+ {todo.attachmentUrl && ( +
+ +
+ )} +
+
+ )}
-
+ {/* Footer Actions */} +
diff --git a/frontend/components/todo-form.tsx b/frontend/components/todo-form.tsx index 6263f82..25b03e2 100644 --- a/frontend/components/todo-form.tsx +++ b/frontend/components/todo-form.tsx @@ -1,9 +1,10 @@ "use client"; import type React from "react"; -import Image from "next/image"; - import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; +import { uploadAttachment, deleteAttachment } from "@/services/api-attachments"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -16,25 +17,33 @@ import { SelectValue, } from "@/components/ui/select"; import { MultiSelect } from "@/components/multi-select"; -import { Icons } from "@/components/icons"; import type { Todo, Tag } from "@/services/api-types"; +import { Progress } from "./ui/progress"; interface TodoFormProps { todo?: Todo; tags: Tag[]; - onSubmit: (todo: Partial) => void; + onSubmit: (todo: Partial) => Promise; + onAttachmentsChanged?: (attachments: string[]) => void; // Now just an array of one or zero } -export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) { +export function TodoForm({ + todo, + tags, + onSubmit, + onAttachmentsChanged, +}: TodoFormProps) { + const { token } = useAuth(); const [formData, setFormData] = useState>({ title: "", description: "", status: "pending", deadline: undefined, tagIds: [], - image: null, + attachmentUrl: null, }); - const [imagePreview, setImagePreview] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); useEffect(() => { if (todo) { @@ -44,12 +53,17 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) { status: todo.status, deadline: todo.deadline, tagIds: todo.tagIds || [], - image: todo.image || null, + attachmentUrl: todo.attachmentUrl || null, + }); + } else { + setFormData({ + title: "", + description: "", + status: "pending", + deadline: undefined, + tagIds: [], + attachmentUrl: null, }); - - if (todo.image) { - setImagePreview(todo.image); - } } }, [todo]); @@ -68,29 +82,85 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) { setFormData((prev) => ({ ...prev, tagIds: selected })); }; - const handleImageChange = (e: React.ChangeEvent) => { + // Upload new attachment + const handleFileChange = async (e: React.ChangeEvent) => { + // Only one attachment is supported, so uploading a new one replaces the old + + if (!todo || !token) { + toast.error("Cannot attach files until the todo is saved."); + return; + } const file = e.target.files?.[0]; if (!file) return; + // Only allow images + if (!file.type.startsWith("image/")) { + toast.error("Only image files are allowed."); + return; + } - // In a real app, we would upload the file to a server and get a URL back - // For now, we'll create a local object URL - const imageUrl = URL.createObjectURL(file); - setImagePreview(imageUrl); - setFormData((prev) => ({ ...prev, image: imageUrl })); + setIsUploading(true); + setUploadProgress(0); + + // Simulate progress for demo – replace if backend supports it + const progressInterval = setInterval(() => { + setUploadProgress((p) => Math.min(p + 10, 90)); + }, 200); + + try { + const response = await uploadAttachment(todo.id, file, token); + clearInterval(progressInterval); + setUploadProgress(100); + toast.success(`Uploaded: "${response.fileName}"`); + + setFormData((f) => ({ ...f, attachmentUrl: response.fileUrl })); + onAttachmentsChanged?.([response.fileUrl]); + + setTimeout(() => { + setIsUploading(false); + setUploadProgress(0); + }, 500); + } catch (err) { + clearInterval(progressInterval); + setIsUploading(false); + setUploadProgress(0); + console.error(err); + toast.error( + `Upload failed: ${err instanceof Error ? err.message : "Unknown error"}` + ); + } finally { + e.target.value = ""; + } }; - const handleRemoveImage = () => { - setImagePreview(null); - setFormData((prev) => ({ ...prev, image: null })); + // Remove an existing attachment + const handleRemoveAttachment = async (attachmentId: string) => { + // Only one attachment is supported, so attachmentId is ignored + + if (!todo || !token) { + toast.error("Cannot remove attachments right now."); + return; + } + try { + await deleteAttachment(todo.id, attachmentId, token); + // Only one attachment is supported now, so just clear the attachmentUrl + setFormData((f) => ({ ...f, attachmentUrl: null })); + onAttachmentsChanged?.([]); + toast.success("Attachment removed."); + } catch (err) { + console.error(err); + toast.error( + `Delete failed: ${err instanceof Error ? err.message : "Unknown error"}` + ); + } }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - onSubmit(formData); + await onSubmit(formData); }; - return (
+ {/* Title, Description, Status, Deadline, Tags... */}
-
- -
+ + {/* Attachment Section - Only shown when editing an existing todo */} + {todo && ( +
+ - {imagePreview && ( - + {isUploading && ( + )}
- {imagePreview && ( -
- Preview -
- )} -
- + + +
+ )} +
); diff --git a/frontend/components/todo-row.tsx b/frontend/components/todo-row.tsx index be08ea8..9c14225 100644 --- a/frontend/components/todo-row.tsx +++ b/frontend/components/todo-row.tsx @@ -1,56 +1,116 @@ -"use client" +"use client"; -import { useState } from "react" -import { toast } from "sonner" -import { useSortable } from "@dnd-kit/sortable" -import { CSS } from "@dnd-kit/utilities" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { TodoForm } from "@/components/todo-form" -import { Icons } from "@/components/icons" -import { cn } from "@/lib/utils" -import type { Todo, Tag } from "@/services/api-types" +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import Image from "next/image"; // Import Image +import { useAuth } from "@/hooks/use-auth"; +import { deleteAttachment } from "@/services/api-attachments"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; // Import AlertDialog +import { TodoForm } from "@/components/todo-form"; +import { Icons } from "@/components/icons"; +import { cn } from "@/lib/utils"; +import type { Todo, Tag } from "@/services/api-types"; interface TodoRowProps { - todo: Todo - tags: Tag[] - onUpdate: (todo: Partial) => void - onDelete: () => void - isDraggable?: boolean + todo: Todo; + tags: Tag[]; + onUpdate: (todo: Partial) => Promise; // Make async + onDelete: () => void; + onAttachmentsChanged?: (attachments: AttachmentInfo[]) => void; // Add callback + isDraggable?: boolean; } -export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }: TodoRowProps) { - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [isViewDialogOpen, setIsViewDialogOpen] = useState(false) +export function TodoRow({ + todo, + tags, + onUpdate, + onDelete, + onAttachmentsChanged, + isDraggable = false, +}: TodoRowProps) { + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isViewDialogOpen, setIsViewDialogOpen] = useState(false); + const { token } = useAuth(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: todo.id, disabled: !isDraggable, - }) + }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - } + }; - const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id)) + const todoTags = tags.filter((tag) => todo.tagIds?.includes(tag.id)); + const hasAttachments = todo.attachments && todo.attachments.length > 0; const handleStatusToggle = () => { - const newStatus = todo.status === "completed" ? "pending" : "completed" - onUpdate({ status: newStatus }) - } + const newStatus = todo.status === "completed" ? "pending" : "completed"; + onUpdate({ status: newStatus }); + }; const formatDate = (dateString?: string | null) => { - if (!dateString) return null - const date = new Date(dateString) - return date.toLocaleDateString() - } + if (!dateString) return null; + const date = new Date(dateString); + return date.toLocaleDateString(); + }; - // Check if todo has image or attachments - const hasAttachments = todo.attachments && todo.attachments.length > 0 + const handleDeleteAttachment = async (attachmentId: string) => { + if (!token) { + toast.error("Authentication required."); + return; + } + try { + await deleteAttachment(todo.id, attachmentId, token); + toast.success("Attachment deleted."); + const updatedAttachments = todo.attachments.filter( + (att) => att.fileId !== attachmentId + ); + onAttachmentsChanged?.(updatedAttachments); + // Also update the local state for the dialog if it's open + setFormData((prev) => ({ ...prev, attachments: updatedAttachments })); + } catch (error) { + console.error("Failed to delete attachment:", error); + toast.error("Failed to delete attachment."); + } + }; + + // State to manage form data within the row component context if needed, e.g., for the view dialog + const [formData, setFormData] = useState(todo); + useEffect(() => { + setFormData(todo); + }, [todo]); return ( <> @@ -61,170 +121,387 @@ export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }: "group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 rounded-md transition-colors", todo.status === "completed" ? "text-muted-foreground" : "", isDraggable ? "cursor-grab active:cursor-grabbing" : "", - isDragging ? "shadow-lg bg-muted" : "", + isDragging ? "shadow-lg bg-muted" : "" )} {...(isDraggable ? { ...attributes, ...listeners } : {})} > handleStatusToggle()} - className="h-5 w-5 rounded-full" + className="h-5 w-5 rounded-full flex-shrink-0" // Added flex-shrink-0 /> -
+
setIsViewDialogOpen(true)} + > + {" "} + {/* Make main area clickable */}
- + {todo.title} - {todo.image && } - {hasAttachments && } + {/* Indicators */} +
+ {hasAttachments && ( + + )} + {/* Add other indicators like subtasks if needed */} +
- - {todo.description &&

{todo.description}

} + {todo.description && ( +

+ {todo.description} +

+ )}
-
+ {/* Right-aligned section */} +
+ {" "} + {/* Added ml-auto and flex-shrink-0 */} + {/* Tags */} {todoTags.length > 0 && ( -
+
+ {" "} + {/* Hide tags on very small screens */} {todoTags.slice(0, 2).map((tag) => (
))} - {todoTags.length > 2 && +{todoTags.length - 2}} + {todoTags.length > 2 && ( + + +{todoTags.length - 2} + + )}
)} - + {/* Deadline */} {todo.deadline && ( - {formatDate(todo.deadline)} + + {formatDate(todo.deadline)} + )} - -
- - - + {/* Delete Confirmation */} + + + + + + + Delete Todo? + + Are you sure you want to delete "{todo.title}"? + This action cannot be undone. + + + + Cancel + + Delete + + + +
+ {/* Edit Dialog */} - + Edit Todo - Update your todo details + + Update your todo details and attachments. + { - onUpdate(updatedTodo) - setIsEditDialogOpen(false) - toast.success("Todo updated successfully") + onSubmit={async (updatedTodoData) => { + await onUpdate(updatedTodoData); + setIsEditDialogOpen(false); + }} + onAttachmentsChanged={(newAttachments) => { + // If the parent needs immediate notification of attachment changes + onAttachmentsChanged?.(newAttachments); + // Optionally update local state if needed within this component context + setFormData((prev) => ({ ...prev, attachments: newAttachments })); }} /> + {/* View Dialog */} - - - {todo.title} - Created on {new Date(todo.createdAt).toLocaleDateString()} + {/* Re-use the View Dialog structure from TodoCard, adapting as needed */} + + + {/* ... Header content (status badge, title, etc.) ... */} +
+ {/* Status Badge */} + +
+ {todo.status.replace("-", " ")} + + {/* Deadline Badge */} + {todo.deadline && ( + + {" "} + {" "} + {formatDate(todo.deadline)}{" "} + + )} +
+ + {formData.title} + + + Created {formatDate(formData.createdAt)} + - {todo.image && ( -
- {todo.title} + att.contentType.startsWith("image/") + ) && ( +
+ + att.contentType.startsWith("image/") + )?.fileUrl || "/placeholder.svg" + } + alt={formData.title} + fill + style={{ objectFit: "cover" }} + sizes="450px" />
)} -
-
-

Status

- - {todo.status.replace("-", " ")} - -
- {todo.deadline && ( -
-

Deadline

-

- - {formatDate(todo.deadline)} -

-
- )} - {todo.description && ( -
-

Description

-

{todo.description}

+ +
+ {/* Description */} + {formData.description && ( +
+ {formData.description}
)} + {/* Tags */} {todoTags.length > 0 && (
-

Tags

-
+ {" "} +

+ TAGS +

{" "} +
{todoTags.map((tag) => ( - + {tag.name} ))} +
{" "} +
+ )} + {/* Subtasks */} + {formData.subtasks && formData.subtasks.length > 0 && ( +
+

+ SUBTASKS ( + {formData.subtasks.filter((s) => s.completed).length}/ + {formData.subtasks.length}) +

+ {/* ... Subtask list rendering ... */} +
+ )} + {/* Attachments */} + {(formData.attachments?.length ?? 0) > 0 && ( +
+

+ ATTACHMENTS +

+
+ {formData.attachments.map((att) => ( +
+
+ {/* Icon or Thumbnail */} + {att.contentType.startsWith("image/") ? ( +
+ {att.fileName} +
+ ) : ( +
+ +
+ )} + {/* File Info */} +
+ + {" "} + {att.fileName}{" "} + + + {" "} + {(att.size / 1024).toFixed(1)} KB -{" "} + {att.contentType}{" "} + +
+
+ {/* Delete Button */} + + + + + + + {" "} + + Delete Attachment? + {" "} + + {" "} + Are you sure you want to delete " + {att.fileName}"? This action cannot be + undone.{" "} + {" "} + + + {" "} + Cancel{" "} + handleDeleteAttachment(att.fileId)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {" "} + Delete{" "} + {" "} + + + +
+ ))}
)} - {hasAttachments && ( -
-

Attachments

-
    - {todo.attachments.map((attachment, index) => ( -
  • - - {attachment} -
  • - ))} -
-
- )} - {todo.subtasks && todo.subtasks.length > 0 && ( -
-

Subtasks

-
    - {todo.subtasks.map((subtask) => ( -
  • - - - {subtask.description} - -
  • - ))} -
-
- )} +
+ {/* Footer Actions */} +
+ +
- ) + ); } diff --git a/frontend/hooks/use-tags.ts b/frontend/hooks/use-tags.ts index f68bba0..e971ea7 100644 --- a/frontend/hooks/use-tags.ts +++ b/frontend/hooks/use-tags.ts @@ -10,7 +10,7 @@ export function useTags() { return useQuery({ queryKey: ["tags"], - queryFn: () => listUserTags(token), + queryFn: () => listUserTags(token || undefined), enabled: !!token, }) } diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..fdf5fa4 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "storage.googleapis.com", + }, + ], + }, }; export default nextConfig; diff --git a/frontend/services/api-attachments.ts b/frontend/services/api-attachments.ts new file mode 100644 index 0000000..404423d --- /dev/null +++ b/frontend/services/api-attachments.ts @@ -0,0 +1,44 @@ +import { apiClient } from "./api-client"; +import type { FileUploadResponse } from "./api-types"; + +/** + * Uploads a file attachment to a specific todo item. + * @param todoId - The ID of the todo item. + * @param file - The File object to upload. + * @param token - The authentication token. + * @returns A promise resolving to the FileUploadResponse. + */ +export async function uploadAttachment( + todoId: string, + file: File, + token: string +): Promise { + const formData = new FormData(); + formData.append("file", file); + + return await apiClient.upload( + `/todos/${todoId}/attachments`, + formData, + token + ); +} + +/** + * Deletes an attachment from a specific todo item. + * @param todoId - The ID of the todo item. + * @param attachmentPath - The storage path/ID of the attachment to delete (URL-encoded). + * @param token - The authentication token. + * @returns A promise resolving when the deletion is complete. + */ +export async function deleteAttachment( + todoId: string, + attachmentPath: string, // This is the FileID from AttachmentInfo + token: string +): Promise { + // Ensure the path is URL encoded for the request path + const encodedPath = encodeURIComponent(attachmentPath); + await apiClient.delete( + `/todos/${todoId}/attachments/${encodedPath}`, + token + ); +} diff --git a/frontend/services/api-client.ts b/frontend/services/api-client.ts index 0b644b2..7dffcf6 100644 --- a/frontend/services/api-client.ts +++ b/frontend/services/api-client.ts @@ -1,84 +1,155 @@ -// Base API client for making requests to the backend +import { useAuthStore } from "@/store/auth-store"; -// Helper for simulating network delay in development -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -// Get the API base URL from environment variable or use default -const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1" +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1"; -// Request options with authentication token if available -const getRequestOptions = (token?: string | null) => { - const headers: HeadersInit = { - "Content-Type": "application/json", +const getRequestOptions = (token?: string | null, isFormData = false) => { + const headers: HeadersInit = {}; + + if (!isFormData) { + headers["Content-Type"] = "application/json"; } if (token) { - headers["Authorization"] = `Bearer ${token}` + headers["Authorization"] = `Bearer ${token}`; } - return { headers } -} + return { headers }; +}; -// Generic fetch function with error handling -export async function apiFetch( +async function apiFetch( endpoint: string, options: RequestInit = {}, token?: string | null, - simulateDelay = process.env.NODE_ENV === "development" ? 300 : 0, // Only simulate delay in development + isFormData = false, + simulateDelay = process.env.NODE_ENV === "development" ? 300 : 0 ): Promise { if (simulateDelay > 0) { - await delay(simulateDelay) + await delay(simulateDelay); } - const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}` + const url = `${API_BASE_URL}${ + endpoint.startsWith("/") ? endpoint : `/${endpoint}` + }`; + + // Merge headers carefully + const baseOptions = getRequestOptions(token, isFormData); const requestOptions = { ...options, headers: { - ...getRequestOptions(token).headers, - ...options.headers, + ...baseOptions.headers, // Use headers from getRequestOptions + ...options.headers, // Allow overriding/adding headers from options }, - } + }; - const response = await fetch(url, requestOptions) + const response = await fetch(url, requestOptions); - // Handle non-2xx responses if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.message || `API error: ${response.status}`) + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || `API error: ${response.status} ${response.statusText}` + ); } - // Handle empty responses (like for DELETE operations) - const contentType = response.headers.get("content-type") + // Handle empty responses (like 204 No Content) + if (response.status === 204) { + return {} as T; + } + + const contentType = response.headers.get("content-type"); if (contentType?.includes("application/json")) { - return (await response.json()) as T + return (await response.json()) as T; } - return {} as T + // Handle other content types if necessary, or return raw response? + // For now, assume JSON or empty for successful non-error responses + return {} as T; } -// Convenience methods for common HTTP methods -export const apiClient = { - get: (endpoint: string, token?: string | null) => - apiFetch(endpoint, { method: 'GET' }, token), - - post: (endpoint: string, data: unknown, token?: string | null) => - apiFetch(endpoint, { - method: 'POST', - body: JSON.stringify(data) - }, token), - - put: (endpoint: string, data: unknown, token?: string | null) => - apiFetch(endpoint, { - method: 'PUT', - body: JSON.stringify(data) - }, token), - - patch: (endpoint: string, data: unknown, token?: string | null) => - apiFetch(endpoint, { - method: 'PATCH', - body: JSON.stringify(data) - }, token), - - delete: (endpoint: string, token?: string | null) => - apiFetch(endpoint, { method: 'DELETE' }, token), +// Add a function specifically for FormData uploads +async function apiUpload( + endpoint: string, + formData: FormData, + token?: string | null +): Promise { + // Get token from store if not provided explicitly + const authToken = token ?? useAuthStore.getState().token; + + const url = `${API_BASE_URL}${ + endpoint.startsWith("/") ? endpoint : `/${endpoint}` + }`; + + const headers: HeadersInit = {}; + if (authToken) { + headers["Authorization"] = `Bearer ${authToken}`; + } + // DO NOT set Content-Type header for FormData + + const response = await fetch(url, { + method: "POST", + body: formData, + headers: headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || `API error: ${response.status} ${response.statusText}` + ); + } + + if (response.status === 204) { + return {} as T; + } + + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return (await response.json()) as T; + } + + return {} as T; } + +export const apiClient = { + get: (endpoint: string, token?: string | null) => + apiFetch(endpoint, { method: "GET" }, token), + + post: (endpoint: string, data: unknown, token?: string | null) => + apiFetch( + endpoint, + { + method: "POST", + body: JSON.stringify(data), + }, + token + ), + + put: (endpoint: string, data: unknown, token?: string | null) => + apiFetch( + endpoint, + { + method: "PUT", + body: JSON.stringify(data), + }, + token + ), + + patch: (endpoint: string, data: unknown, token?: string | null) => + apiFetch( + endpoint, + { + method: "PATCH", + body: JSON.stringify(data), + }, + token + ), + + delete: (endpoint: string, token?: string | null) => + apiFetch(endpoint, { method: "DELETE" }, token), + + // Expose the upload function + upload: (endpoint: string, formData: FormData, token?: string | null) => + apiUpload(endpoint, formData, token), +}; diff --git a/frontend/services/api-types.ts b/frontend/services/api-types.ts index f40dd4b..9cf9752 100644 --- a/frontend/services/api-types.ts +++ b/frontend/services/api-types.ts @@ -1,5 +1,3 @@ -// Generated types based on the OpenAPI spec - export interface User { id: string username: string @@ -59,8 +57,7 @@ export interface Todo { status: "pending" | "in-progress" | "completed" deadline?: string | null tagIds: string[] - attachments: string[] - image?: string | null + attachmentUrl?: string | null subtasks: Subtask[] createdAt: string updatedAt: string @@ -72,7 +69,6 @@ export interface CreateTodoRequest { status?: "pending" | "in-progress" | "completed" deadline?: string | null tagIds?: string[] - image?: string | null } export interface UpdateTodoRequest { @@ -81,8 +77,7 @@ export interface UpdateTodoRequest { status?: "pending" | "in-progress" | "completed" deadline?: string | null tagIds?: string[] - attachments?: string[] - image?: string | null + // attachments are managed via separate endpoints, removed from here } export interface Subtask { @@ -103,15 +98,15 @@ export interface UpdateSubtaskRequest { completed?: boolean } -export interface FileUploadResponse { - fileId: string - fileName: string - fileUrl: string - contentType: string - size: number +export type FileUploadResponse = { + fileId: string // Identifier used for deletion (e.g., GCS object path) + fileName: string // Original filename + fileUrl: string // Publicly accessible URL (e.g., signed URL) + contentType: string // MIME type + size: number // Size in bytes } export interface Error { - code: number - message: string + code: number + message: string }