mirror of
https://github.com/Sosokker/go-chi-oapi-codegen-todolist.git
synced 2025-12-19 05:54:07 +01:00
feat: store attachment
This commit is contained in:
parent
f4bc48c337
commit
1ddffbc026
@ -73,18 +73,14 @@ test:
|
|||||||
|
|
||||||
migrate-up:
|
migrate-up:
|
||||||
@echo ">> Applying migrations..."
|
@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) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) up
|
||||||
|
|
||||||
migrate-down:
|
migrate-down:
|
||||||
@echo ">> Rolling back last migration..."
|
@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) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) down 1
|
||||||
|
|
||||||
migrate-force:
|
migrate-force:
|
||||||
@echo ">> Forcing migration version $(VERSION)..."
|
@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=<version_number>"; exit 1; fi
|
|
||||||
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION)
|
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@ -56,14 +56,7 @@ func main() {
|
|||||||
repoRegistry := repository.NewRepositoryRegistry(pool)
|
repoRegistry := repository.NewRepositoryRegistry(pool)
|
||||||
|
|
||||||
var storageService service.FileStorageService
|
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)
|
storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger)
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unsupported storage type: %s", cfg.Storage.Type)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type)
|
logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@ -7,6 +7,9 @@ server:
|
|||||||
idleTimeout: 60s
|
idleTimeout: 60s
|
||||||
basePath: "/api/v1" # Matches OpenAPI server URL
|
basePath: "/api/v1" # Matches OpenAPI server URL
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
url: "http://localhost:3000"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod
|
url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod
|
||||||
|
|
||||||
@ -39,7 +42,5 @@ cache:
|
|||||||
cleanupInterval: 10m
|
cleanupInterval: 10m
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
local:
|
bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME
|
||||||
path: "/" # gcs:
|
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
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ tool (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/storage v1.51.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
@ -37,6 +38,7 @@ require (
|
|||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
golang.org/x/crypto v0.37.0
|
golang.org/x/crypto v0.37.0
|
||||||
golang.org/x/oauth2 v0.29.0
|
golang.org/x/oauth2 v0.29.0
|
||||||
|
google.golang.org/api v0.229.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -47,7 +49,6 @@ require (
|
|||||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||||
cloud.google.com/go/iam v1.4.1 // indirect
|
cloud.google.com/go/iam v1.4.1 // indirect
|
||||||
cloud.google.com/go/monitoring v1.24.0 // 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/detectors/gcp v1.25.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.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
|
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/text v0.24.0 // indirect
|
||||||
golang.org/x/time v0.11.0 // indirect
|
golang.org/x/time v0.11.0 // indirect
|
||||||
golang.org/x/tools v0.24.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 v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||||
google.golang.org/genproto/googleapis/api 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
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4=
|
cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4=
|
||||||
cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
|
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 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=
|
||||||
cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=
|
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=
|
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/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 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM=
|
||||||
cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM=
|
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 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM=
|
||||||
cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc=
|
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 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw=
|
||||||
cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc=
|
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 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
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 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/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 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/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 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
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=
|
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 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
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 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 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
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 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
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=
|
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.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
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.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.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.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.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.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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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/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 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
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 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
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/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 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/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 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
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 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
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/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc=
|
||||||
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/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I=
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
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/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 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
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 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
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 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
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=
|
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-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-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
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 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
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=
|
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=
|
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 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8=
|
||||||
google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0=
|
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 h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
|
||||||
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
|
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=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
|
||||||
|
|||||||
@ -139,7 +139,7 @@ func mapDomainTagToApi(tag *domain.Tag) *models.Tag {
|
|||||||
UpdatedAt: &updatedAt}
|
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 {
|
if todo == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -169,10 +169,11 @@ func mapDomainTodoToApi(todo *domain.Todo) *models.Todo {
|
|||||||
Status: models.TodoStatus(todo.Status),
|
Status: models.TodoStatus(todo.Status),
|
||||||
Deadline: todo.Deadline,
|
Deadline: todo.Deadline,
|
||||||
TagIds: tagIDs,
|
TagIds: tagIDs,
|
||||||
Attachments: todo.Attachments,
|
AttachmentUrl: todo.AttachmentUrl,
|
||||||
Subtasks: &apiSubtasks,
|
Subtasks: &apiSubtasks,
|
||||||
CreatedAt: &createdAt,
|
CreatedAt: &createdAt,
|
||||||
UpdatedAt: &updatedAt}
|
UpdatedAt: &updatedAt,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
|
func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
|
||||||
@ -193,11 +194,11 @@ func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
|
|||||||
UpdatedAt: &updatedAt}
|
UpdatedAt: &updatedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.FileUploadResponse {
|
func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.AttachmentInfo {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &models.FileUploadResponse{
|
return &models.AttachmentInfo{
|
||||||
FileId: info.FileID,
|
FileId: info.FileID,
|
||||||
FileName: info.FileName,
|
FileName: info.FileName,
|
||||||
FileUrl: info.FileURL,
|
FileUrl: info.FileURL,
|
||||||
@ -577,6 +578,8 @@ func (h *ApiHandler) DeleteTagById(w http.ResponseWriter, r *http.Request, tagId
|
|||||||
|
|
||||||
// --- Todo Handlers ---
|
// --- Todo Handlers ---
|
||||||
|
|
||||||
|
// CreateTodo remains the same
|
||||||
|
|
||||||
func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
|
func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, err := GetUserIDFromContext(r.Context())
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -615,10 +618,12 @@ func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
|
|||||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiTodo := mapDomainTodoToApi(todo)
|
// Newly created todo won't have attachments yet
|
||||||
|
apiTodo := mapDomainTodoToApi(todo, []models.AttachmentInfo{})
|
||||||
SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger)
|
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) {
|
func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) {
|
||||||
userID, err := GetUserIDFromContext(r.Context())
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -627,7 +632,7 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
|
|||||||
}
|
}
|
||||||
|
|
||||||
input := service.ListTodosInput{
|
input := service.ListTodosInput{
|
||||||
Limit: 20,
|
Limit: 20, // Default limit
|
||||||
Offset: 0,
|
Offset: 0,
|
||||||
}
|
}
|
||||||
if params.Limit != nil {
|
if params.Limit != nil {
|
||||||
@ -641,13 +646,8 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
|
|||||||
input.Status = &domainStatus
|
input.Status = &domainStatus
|
||||||
}
|
}
|
||||||
if params.TagId != nil {
|
if params.TagId != nil {
|
||||||
input.TagID = params.TagId
|
domainTagID := uuid.UUID(*params.TagId)
|
||||||
}
|
input.TagID = &domainTagID
|
||||||
if params.DeadlineBefore != nil {
|
|
||||||
input.DeadlineBefore = params.DeadlineBefore
|
|
||||||
}
|
|
||||||
if params.DeadlineAfter != nil {
|
|
||||||
input.DeadlineAfter = params.DeadlineAfter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input)
|
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))
|
apiTodos := make([]models.Todo, len(todos))
|
||||||
for i, todo := range 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)
|
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) {
|
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 {
|
if err != nil {
|
||||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
todo, err := h.services.Todo.GetTodoByID(r.Context(), todoId, userID)
|
// Map attachmentUrl to API model as a single-item array if present
|
||||||
if err != nil {
|
var apiAttachmentInfos []models.AttachmentInfo
|
||||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
||||||
return
|
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) {
|
func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
userID, err := GetUserIDFromContext(r.Context())
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -711,9 +735,7 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo
|
|||||||
}
|
}
|
||||||
input.TagIDs = &domainTagIDs
|
input.TagIDs = &domainTagIDs
|
||||||
}
|
}
|
||||||
if body.Attachments != nil {
|
// Note: Attachments are NOT updated via this endpoint in this design
|
||||||
input.Attachments = body.Attachments
|
|
||||||
}
|
|
||||||
|
|
||||||
todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input)
|
todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -721,17 +743,28 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo
|
|||||||
return
|
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) {
|
func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||||
userID, err := GetUserIDFromContext(r.Context())
|
userID, err := GetUserIDFromContext(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||||
return
|
return
|
||||||
@ -740,6 +773,72 @@ func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todo
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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 ---
|
// --- Subtask Handlers ---
|
||||||
|
|
||||||
func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
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)
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -42,6 +42,24 @@ const (
|
|||||||
ListTodosParamsStatusPending ListTodosParamsStatus = "pending"
|
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.
|
// CreateSubtaskRequest Data required to create a new Subtask.
|
||||||
type CreateSubtaskRequest struct {
|
type CreateSubtaskRequest struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@ -82,23 +100,8 @@ type Error struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileUploadResponse Response after successfully uploading a file.
|
// FileUploadResponse Metadata about an uploaded attachment.
|
||||||
type FileUploadResponse struct {
|
type FileUploadResponse = AttachmentInfo
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginRequest Data required for logging in via email/password.
|
// LoginRequest Data required for logging in via email/password.
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
@ -157,35 +160,21 @@ type Tag struct {
|
|||||||
|
|
||||||
// Todo Represents a Todo item.
|
// Todo Represents a Todo item.
|
||||||
type Todo struct {
|
type Todo struct {
|
||||||
// Attachments List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints.
|
// AttachmentUrl Publicly accessible URL of the attached image, if any.
|
||||||
Attachments []string `json:"attachments"`
|
AttachmentUrl *string `json:"attachmentUrl"`
|
||||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
|
|
||||||
// Deadline Optional deadline for the Todo item.
|
|
||||||
Deadline *time.Time `json:"deadline"`
|
Deadline *time.Time `json:"deadline"`
|
||||||
|
|
||||||
// Description Optional detailed description of the Todo.
|
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Id *openapi_types.UUID `json:"id,omitempty"`
|
Id *openapi_types.UUID `json:"id,omitempty"`
|
||||||
|
|
||||||
// Status Current status of the Todo item.
|
|
||||||
Status TodoStatus `json:"status"`
|
Status TodoStatus `json:"status"`
|
||||||
|
|
||||||
// Subtasks List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints.
|
|
||||||
Subtasks *[]Subtask `json:"subtasks,omitempty"`
|
Subtasks *[]Subtask `json:"subtasks,omitempty"`
|
||||||
|
|
||||||
// TagIds List of IDs of Tags associated with this Todo.
|
|
||||||
TagIds []openapi_types.UUID `json:"tagIds"`
|
TagIds []openapi_types.UUID `json:"tagIds"`
|
||||||
|
|
||||||
// Title The main title or task of the Todo.
|
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
|
||||||
// UserId The ID of the user who owns this Todo.
|
|
||||||
UserId *openapi_types.UUID `json:"userId,omitempty"`
|
UserId *openapi_types.UUID `json:"userId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TodoStatus Current status of the Todo item.
|
// TodoStatus defines model for Todo.Status.
|
||||||
type TodoStatus string
|
type TodoStatus string
|
||||||
|
|
||||||
// UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional.
|
// UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional.
|
||||||
@ -206,15 +195,11 @@ type UpdateTagRequest struct {
|
|||||||
Name *string `json:"name,omitempty"`
|
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 {
|
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"`
|
Deadline *time.Time `json:"deadline"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status *UpdateTodoRequestStatus `json:"status,omitempty"`
|
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"`
|
TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
}
|
}
|
||||||
@ -259,30 +244,17 @@ type Unauthorized = Error
|
|||||||
|
|
||||||
// ListTodosParams defines parameters for ListTodos.
|
// ListTodosParams defines parameters for ListTodos.
|
||||||
type ListTodosParams struct {
|
type ListTodosParams struct {
|
||||||
// Status Filter Todos by status.
|
|
||||||
Status *ListTodosParamsStatus `form:"status,omitempty" json:"status,omitempty"`
|
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"`
|
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"`
|
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
|
||||||
|
|
||||||
// Offset Number of Todos to skip for pagination.
|
|
||||||
Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
|
Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTodosParamsStatus defines parameters for ListTodos.
|
// ListTodosParamsStatus defines parameters for ListTodos.
|
||||||
type ListTodosParamsStatus string
|
type ListTodosParamsStatus string
|
||||||
|
|
||||||
// UploadTodoAttachmentMultipartBody defines parameters for UploadTodoAttachment.
|
// UploadOrReplaceTodoAttachmentMultipartBody defines parameters for UploadOrReplaceTodoAttachment.
|
||||||
type UploadTodoAttachmentMultipartBody struct {
|
type UploadOrReplaceTodoAttachmentMultipartBody struct {
|
||||||
File openapi_types.File `json:"file"`
|
File openapi_types.File `json:"file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,8 +276,8 @@ type CreateTodoJSONRequestBody = CreateTodoRequest
|
|||||||
// UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType.
|
// UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType.
|
||||||
type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest
|
type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest
|
||||||
|
|
||||||
// UploadTodoAttachmentMultipartRequestBody defines body for UploadTodoAttachment for multipart/form-data ContentType.
|
// UploadOrReplaceTodoAttachmentMultipartRequestBody defines body for UploadOrReplaceTodoAttachment for multipart/form-data ContentType.
|
||||||
type UploadTodoAttachmentMultipartRequestBody UploadTodoAttachmentMultipartBody
|
type UploadOrReplaceTodoAttachmentMultipartRequestBody UploadOrReplaceTodoAttachmentMultipartBody
|
||||||
|
|
||||||
// CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType.
|
// CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType.
|
||||||
type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest
|
type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest
|
||||||
|
|||||||
@ -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"`
|
|
||||||
}
|
|
||||||
@ -23,13 +23,23 @@ type Todo struct {
|
|||||||
Status TodoStatus `json:"status"`
|
Status TodoStatus `json:"status"`
|
||||||
Deadline *time.Time `json:"deadline"` // Nullable
|
Deadline *time.Time `json:"deadline"` // Nullable
|
||||||
TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching
|
TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching
|
||||||
Tags []Tag `json:"-"` // Can hold full tag objects if needed, loaded separately
|
Tags []Tag `json:"-"` // Loaded separately
|
||||||
Attachments []string `json:"attachments"` // Stores identifiers (e.g., file IDs or URLs)
|
AttachmentUrl *string `json:"attachmentUrl"` // Renamed and changed type
|
||||||
Subtasks []Subtask `json:"subtasks"` // Populated after fetching
|
Subtasks []Subtask `json:"subtasks"` // Populated after fetching
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 {
|
func NullStringToStringPtr(ns sql.NullString) *string {
|
||||||
if ns.Valid {
|
if ns.Valid {
|
||||||
return &ns.String
|
return &ns.String
|
||||||
|
|||||||
@ -40,7 +40,7 @@ type ListTodosParams struct {
|
|||||||
TagID *uuid.UUID
|
TagID *uuid.UUID
|
||||||
DeadlineBefore *time.Time
|
DeadlineBefore *time.Time
|
||||||
DeadlineAfter *time.Time
|
DeadlineAfter *time.Time
|
||||||
ListParams // Embed pagination
|
ListParams
|
||||||
}
|
}
|
||||||
|
|
||||||
type TodoRepository interface {
|
type TodoRepository interface {
|
||||||
@ -54,10 +54,8 @@ type TodoRepository interface {
|
|||||||
RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error
|
RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error
|
||||||
SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error
|
SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error
|
||||||
GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error)
|
GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error)
|
||||||
// Attachment associations (using simple string array)
|
// Attachment URL management
|
||||||
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
UpdateAttachmentURL(ctx context.Context, todoID, userID uuid.UUID, attachmentURL *string) error
|
||||||
RemoveAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
|
||||||
SetAttachments(ctx context.Context, todoID, userID uuid.UUID, attachmentIDs []string) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtaskRepository interface {
|
type SubtaskRepository interface {
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
-- name: CreateTodo :one
|
-- name: CreateTodo :one
|
||||||
INSERT INTO todos (user_id, title, description, status, deadline)
|
INSERT INTO todos (user_id, title, description, status, deadline, attachment_url)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetTodoByID :one
|
-- name: GetTodoByID :one
|
||||||
@ -11,13 +11,13 @@ WHERE id = $1 AND user_id = $2 LIMIT 1;
|
|||||||
SELECT t.* FROM todos t
|
SELECT t.* FROM todos t
|
||||||
LEFT JOIN todo_tags tt ON t.id = tt.todo_id
|
LEFT JOIN todo_tags tt ON t.id = tt.todo_id
|
||||||
WHERE
|
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('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('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_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'))
|
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
|
GROUP BY t.id
|
||||||
ORDER BY t.created_at DESC -- Or your desired order
|
ORDER BY t.created_at DESC
|
||||||
LIMIT sqlc.arg('limit')
|
LIMIT sqlc.arg('limit')
|
||||||
OFFSET sqlc.arg('offset');
|
OFFSET sqlc.arg('offset');
|
||||||
|
|
||||||
@ -25,10 +25,10 @@ OFFSET sqlc.arg('offset');
|
|||||||
UPDATE todos
|
UPDATE todos
|
||||||
SET
|
SET
|
||||||
title = COALESCE(sqlc.narg(title), title),
|
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),
|
status = COALESCE(sqlc.narg(status), status),
|
||||||
deadline = sqlc.narg(deadline), -- Allow setting deadline to NULL
|
deadline = sqlc.narg(deadline),
|
||||||
attachments = COALESCE(sqlc.narg(attachments), attachments)
|
attachment_url = COALESCE(sqlc.narg(attachment_url), attachment_url) -- Update attachment_url
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@ -36,12 +36,8 @@ RETURNING *;
|
|||||||
DELETE FROM todos
|
DELETE FROM todos
|
||||||
WHERE id = $1 AND user_id = $2;
|
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
|
UPDATE todos
|
||||||
SET attachments = array_append(attachments, $1)
|
SET attachment_url = $1 -- $1 will be the URL (TEXT) or NULL
|
||||||
WHERE id = $2 AND user_id = $3;
|
|
||||||
|
|
||||||
-- name: RemoveAttachmentFromTodo :exec
|
|
||||||
UPDATE todos
|
|
||||||
SET attachments = array_remove(attachments, $1)
|
|
||||||
WHERE id = $2 AND user_id = $3;
|
WHERE id = $2 AND user_id = $3;
|
||||||
@ -34,8 +34,8 @@ func mapDbTodoToDomain(dbTodo db.Todo) *domain.Todo {
|
|||||||
Title: dbTodo.Title,
|
Title: dbTodo.Title,
|
||||||
Description: domain.NullStringToStringPtr(dbTodo.Description),
|
Description: domain.NullStringToStringPtr(dbTodo.Description),
|
||||||
Status: domain.TodoStatus(dbTodo.Status),
|
Status: domain.TodoStatus(dbTodo.Status),
|
||||||
|
AttachmentUrl: domain.NullStringToStringPtr(dbTodo.AttachmentUrl),
|
||||||
Deadline: dbTodo.Deadline,
|
Deadline: dbTodo.Deadline,
|
||||||
Attachments: dbTodo.Attachments,
|
|
||||||
CreatedAt: dbTodo.CreatedAt,
|
CreatedAt: dbTodo.CreatedAt,
|
||||||
UpdatedAt: dbTodo.UpdatedAt,
|
UpdatedAt: dbTodo.UpdatedAt,
|
||||||
}
|
}
|
||||||
@ -161,7 +161,6 @@ func (r *pgxTodoRepository) Update(
|
|||||||
Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil},
|
Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil},
|
||||||
Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true},
|
Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true},
|
||||||
Deadline: updateData.Deadline,
|
Deadline: updateData.Deadline,
|
||||||
Attachments: updateData.Attachments,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dbTodo, err := r.q.UpdateTodo(ctx, params)
|
dbTodo, err := r.q.UpdateTodo(ctx, params)
|
||||||
@ -251,61 +250,19 @@ func (r *pgxTodoRepository) GetTags(
|
|||||||
return tags, nil
|
return tags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Attachments (String Identifiers in Array) ---
|
func (r *pgxTodoRepository) UpdateAttachmentURL(
|
||||||
|
|
||||||
func (r *pgxTodoRepository) AddAttachment(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
todoID, userID uuid.UUID,
|
todoID, userID uuid.UUID,
|
||||||
attachmentID string,
|
attachmentURL *string,
|
||||||
) error {
|
) error {
|
||||||
if _, err := r.GetByID(ctx, todoID, userID); err != nil {
|
query := `
|
||||||
return err
|
UPDATE todos
|
||||||
}
|
SET attachment_url = $1
|
||||||
if err := r.q.AddAttachmentToTodo(ctx, db.AddAttachmentToTodoParams{
|
WHERE id = $2 AND user_id = $3
|
||||||
ArrayAppend: attachmentID,
|
`
|
||||||
ID: todoID,
|
_, err := r.pool.Exec(ctx, query, attachmentURL, todoID, userID)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to update attachment URL: %w", 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 nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime"
|
"mime"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cloud.google.com/go/storage"
|
"cloud.google.com/go/storage"
|
||||||
"github.com/Sosokker/todolist-backend/internal/config"
|
"github.com/Sosokker/todolist-backend/internal/config"
|
||||||
@ -20,75 +22,149 @@ type gcsStorageService struct {
|
|||||||
client *storage.Client
|
client *storage.Client
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
baseDir string
|
baseDir string
|
||||||
|
signedURLExpiry time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGCStorageService(cfg config.GCSStorageConfig, logger *slog.Logger) (FileStorageService, error) {
|
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{}
|
opts := []option.ClientOption{}
|
||||||
|
// Prefer environment variable GOOGLE_APPLICATION_CREDENTIALS
|
||||||
|
// Only use CredentialsFile from config if it's explicitly set
|
||||||
if cfg.CredentialsFile != "" {
|
if cfg.CredentialsFile != "" {
|
||||||
opts = append(opts, option.WithCredentialsFile(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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create GCS client: %w", err)
|
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{
|
return &gcsStorageService{
|
||||||
bucket: cfg.BucketName,
|
bucket: cfg.BucketName,
|
||||||
client: client,
|
client: client,
|
||||||
logger: logger.With("service", "gcsstorage"),
|
logger: logger.With("service", "gcsstorage"),
|
||||||
baseDir: cfg.BaseDir,
|
baseDir: strings.Trim(cfg.BaseDir, "/"), // Ensure no leading/trailing slashes
|
||||||
|
signedURLExpiry: 168 * time.Hour, // Default signed URL validity
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *gcsStorageService) GenerateUniqueObjectName(originalFilename string) string {
|
// GenerateUniqueObjectName creates a unique object path within the bucket's base directory.
|
||||||
|
// Example: attachments/<user_uuid>/<todo_uuid>/<file_uuid>.<ext>
|
||||||
|
func (s *gcsStorageService) GenerateUniqueObjectName(userID, todoID uuid.UUID, originalFilename string) string {
|
||||||
ext := filepath.Ext(originalFilename)
|
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) {
|
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))
|
objectName := s.GenerateUniqueObjectName(userID, todoID, originalFilename)
|
||||||
wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctx)
|
|
||||||
wc.ContentType = mime.TypeByExtension(filepath.Ext(originalFilename))
|
ctxUpload, cancel := context.WithTimeout(ctx, 5*time.Minute) // Timeout for upload
|
||||||
wc.ChunkSize = 0
|
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)
|
written, err := io.Copy(wc, reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wc.Close()
|
// Close writer explicitly on error to clean up potential partial uploads
|
||||||
s.logger.ErrorContext(ctx, "Failed to upload to GCS", "error", err, "object", objectName)
|
_ = 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)
|
return "", "", fmt.Errorf("failed to upload to GCS: %w", err)
|
||||||
}
|
}
|
||||||
if written != size {
|
|
||||||
wc.Close()
|
// Close the writer to finalize the upload
|
||||||
s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName)
|
|
||||||
return "", "", fmt.Errorf("file size mismatch during upload")
|
|
||||||
}
|
|
||||||
if err := wc.Close(); err != nil {
|
if err := wc.Close(); err != nil {
|
||||||
s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName)
|
s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName)
|
||||||
return "", "", fmt.Errorf("failed to finalize upload: %w", err)
|
return "", "", fmt.Errorf("failed to finalize upload: %w", err)
|
||||||
}
|
}
|
||||||
contentType := wc.ContentType
|
|
||||||
if contentType == "" {
|
if written != size {
|
||||||
contentType = "application/octet-stream"
|
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
|
return objectName, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error {
|
func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error {
|
||||||
objectName := filepath.Clean(storageID)
|
objectName := filepath.Clean(storageID) // storageID is the object path
|
||||||
if strings.Contains(objectName, "..") {
|
if strings.Contains(objectName, "..") || !strings.HasPrefix(objectName, s.baseDir+"/") && s.baseDir != "" {
|
||||||
s.logger.WarnContext(ctx, "Attempted directory traversal in GCS delete", "storageId", storageID)
|
s.logger.WarnContext(ctx, "Attempted invalid delete operation", "storageId", storageID, "baseDir", s.baseDir)
|
||||||
return fmt.Errorf("invalid storage ID")
|
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)
|
o := s.client.Bucket(s.bucket).Object(objectName)
|
||||||
err := o.Delete(ctx)
|
err := o.Delete(ctxDelete)
|
||||||
if err != nil && err != storage.ErrObjectNotExist {
|
|
||||||
|
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)
|
s.logger.ErrorContext(ctx, "Failed to delete GCS object", "error", err, "storageId", storageID)
|
||||||
return fmt.Errorf("could not delete GCS object: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetURL generates a signed URL for accessing the private GCS object.
|
||||||
func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) {
|
func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) {
|
||||||
objectName := filepath.Clean(storageID)
|
objectName := storageID
|
||||||
url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", s.bucket, objectName)
|
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
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,7 @@ type UpdateTodoInput struct {
|
|||||||
Status *domain.TodoStatus
|
Status *domain.TodoStatus
|
||||||
Deadline *time.Time
|
Deadline *time.Time
|
||||||
TagIDs *[]uuid.UUID
|
TagIDs *[]uuid.UUID
|
||||||
Attachments *[]string
|
// Attachments are managed via separate endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTodosInput struct {
|
type ListTodosInput struct {
|
||||||
@ -92,18 +92,19 @@ type ListTodosInput struct {
|
|||||||
|
|
||||||
type TodoService interface {
|
type TodoService interface {
|
||||||
CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error)
|
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
|
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) // Includes tags
|
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)
|
UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error)
|
||||||
DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) 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)
|
ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error)
|
||||||
CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*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)
|
UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error)
|
||||||
DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error
|
DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error
|
||||||
// Attachment methods
|
// 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
|
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.Todo, error)
|
||||||
DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) 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 ---
|
// --- 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)
|
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 removes the file associated with the given storage identifier.
|
||||||
Delete(ctx context.Context, storageID string) error
|
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)
|
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
|
// ServiceRegistry bundles services
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Sosokker/todolist-backend/internal/domain"
|
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||||
"github.com/Sosokker/todolist-backend/internal/repository"
|
"github.com/Sosokker/todolist-backend/internal/repository"
|
||||||
@ -14,8 +15,8 @@ import (
|
|||||||
|
|
||||||
type todoService struct {
|
type todoService struct {
|
||||||
todoRepo repository.TodoRepository
|
todoRepo repository.TodoRepository
|
||||||
tagService TagService // Depend on TagService for validation
|
tagService TagService
|
||||||
subtaskService SubtaskService // Depend on SubtaskService
|
subtaskService SubtaskService
|
||||||
storageService FileStorageService
|
storageService FileStorageService
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
@ -62,7 +63,7 @@ func (s *todoService) CreateTodo(ctx context.Context, userID uuid.UUID, input Cr
|
|||||||
Status: status,
|
Status: status,
|
||||||
Deadline: input.Deadline,
|
Deadline: input.Deadline,
|
||||||
TagIDs: input.TagIDs,
|
TagIDs: input.TagIDs,
|
||||||
Attachments: []string{},
|
AttachmentUrl: nil, // No attachment on creation
|
||||||
}
|
}
|
||||||
|
|
||||||
createdTodo, err := s.todoRepo.Create(ctx, newTodo)
|
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
|
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
|
return todo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,14 +170,15 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID,
|
|||||||
Description: existingTodo.Description,
|
Description: existingTodo.Description,
|
||||||
Status: existingTodo.Status,
|
Status: existingTodo.Status,
|
||||||
Deadline: existingTodo.Deadline,
|
Deadline: existingTodo.Deadline,
|
||||||
Attachments: existingTodo.Attachments,
|
TagIDs: existingTodo.TagIDs,
|
||||||
|
AttachmentUrl: existingTodo.AttachmentUrl, // Single attachment URL
|
||||||
}
|
}
|
||||||
|
|
||||||
updated := false
|
updated := false
|
||||||
|
|
||||||
if input.Title != nil {
|
if input.Title != nil {
|
||||||
if *input.Title == "" {
|
if err := ValidateTodoTitle(*input.Title); err != nil {
|
||||||
return nil, fmt.Errorf("title cannot be empty: %w", domain.ErrValidation)
|
return nil, err
|
||||||
}
|
}
|
||||||
updateData.Title = *input.Title
|
updateData.Title = *input.Title
|
||||||
updated = true
|
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)
|
s.logger.ErrorContext(ctx, "Failed to update tags for todo", "error", err, "todoId", todoID)
|
||||||
return nil, domain.ErrInternalServer
|
return nil, domain.ErrInternalServer
|
||||||
}
|
}
|
||||||
updateData.TagIDs = *input.TagIDs
|
|
||||||
tagsUpdated = true
|
tagsUpdated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
attachmentsUpdated := false
|
// Update the core fields if anything changed
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var updatedRepoTodo *domain.Todo
|
var updatedRepoTodo *domain.Todo
|
||||||
if updated {
|
if updated {
|
||||||
updatedRepoTodo, err = s.todoRepo.Update(ctx, todoID, userID, updateData)
|
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
|
return nil, domain.ErrInternalServer
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedRepoTodo = updateData
|
// If only tags were updated, we still need the latest full todo data
|
||||||
|
updatedRepoTodo = existingTodo
|
||||||
}
|
}
|
||||||
|
|
||||||
if !updated && (tagsUpdated || attachmentsUpdated) {
|
// If tags were updated, reload the full todo to get the updated TagIDs array
|
||||||
updatedRepoTodo.Title = existingTodo.Title
|
if tagsUpdated {
|
||||||
updatedRepoTodo.Description = existingTodo.Description
|
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)
|
||||||
finalTodo, err := s.GetTodoByID(ctx, todoID, userID)
|
// Return the todo data we have, even if tags might be slightly out of sync temporarily
|
||||||
if err != nil {
|
if updatedRepoTodo != nil {
|
||||||
s.logger.WarnContext(ctx, "Failed to reload todo after update, returning partial data", "error", err, "todoId", todoID)
|
updatedRepoTodo.TagIDs = *input.TagIDs // Manually set IDs based on input
|
||||||
return updatedRepoTodo, nil
|
return updatedRepoTodo, nil
|
||||||
}
|
}
|
||||||
|
return existingTodo, nil // Fallback
|
||||||
|
}
|
||||||
|
return reloadedTodo, 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 {
|
func (s *todoService) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error {
|
||||||
existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
if err != nil {
|
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)
|
err = s.todoRepo.Delete(ctx, todoID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID)
|
s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID)
|
||||||
return domain.ErrInternalServer
|
return domain.ErrInternalServer
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, storageID := range attachmentIDsToDelete {
|
// If there is an attachment, attempt to delete it from storage (best effort)
|
||||||
if err := s.storageService.Delete(ctx, storageID); err != nil {
|
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)
|
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
|
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) {
|
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)
|
return s.subtaskService.Update(ctx, subtaskID, userID, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *todoService) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error {
|
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)
|
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)
|
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
s.logger.ErrorContext(ctx, "Failed to upload attachment to storage", "error", err, "todoId", todoID, "fileName", originalFilename)
|
s.logger.ErrorContext(ctx, "Failed to upload attachment", "error", err, "todoId", todoID)
|
||||||
return nil, domain.ErrInternalServer
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = s.todoRepo.AddAttachment(ctx, todoID, userID, storageID); err != nil {
|
// Construct the public URL for the uploaded file in GCS
|
||||||
s.logger.ErrorContext(ctx, "Failed to add attachment storage ID to todo", "error", err, "todoId", todoID, "storageId", storageID)
|
publicURL, err := s.storageService.GetURL(ctx, storageID)
|
||||||
if delErr := s.storageService.Delete(context.Background(), storageID); delErr != nil {
|
if err != nil {
|
||||||
s.logger.ErrorContext(ctx, "Failed to delete orphaned attachment file after DB error", "deleteError", delErr, "storageId", storageID)
|
s.logger.ErrorContext(ctx, "Failed to generate public URL for attachment", "error", err, "todoId", todoID, "storageId", storageID)
|
||||||
}
|
return nil, err
|
||||||
return nil, domain.ErrInternalServer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 &domain.AttachmentInfo{
|
return nil, err
|
||||||
FileID: storageID,
|
|
||||||
FileName: originalFilename,
|
|
||||||
FileURL: fileURL,
|
|
||||||
ContentType: contentType,
|
|
||||||
Size: fileSize,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, storageID string) error {
|
s.logger.InfoContext(ctx, "Attachment added successfully", "todoId", todoID, "storageId", storageID)
|
||||||
todo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
|
||||||
|
return s.GetTodoByID(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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
found := false
|
if err := s.todoRepo.UpdateAttachmentURL(ctx, todoID, userID, nil); err != nil {
|
||||||
for _, att := range todo.Attachments {
|
s.logger.ErrorContext(ctx, "Failed to update attachment URL in repo", "error", err, "todoId", todoID)
|
||||||
if att == storageID {
|
return err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
20
backend/migrations/000002_add_single_attachment_url.down.sql
Normal file
20
backend/migrations/000002_add_single_attachment_url.down.sql
Normal file
@ -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;
|
||||||
14
backend/migrations/000002_add_single_attachment_url.up.sql
Normal file
14
backend/migrations/000002_add_single_attachment_url.up.sql
Normal file
@ -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.
|
||||||
@ -1,9 +1,9 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Todolist API
|
title: Todolist API
|
||||||
version: 1.2.0 # Incremented version
|
version: 1.3.0 # Incremented version
|
||||||
description: |
|
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.
|
Supports user authentication via email/password (JWT) and Google OAuth.
|
||||||
Designed for use with oapi-codegen and Chi.
|
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.
|
**Note on Tag Deletion:** Deleting a Tag will typically remove its association from any Todo items currently using it.
|
||||||
servers:
|
servers:
|
||||||
# The base path for all API routes defined below.
|
|
||||||
# oapi-codegen will use this when setting up routes with HandlerFromMux.
|
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
description: API version 1
|
description: API version 1
|
||||||
|
|
||||||
components:
|
components:
|
||||||
# Security Schemes used by the API
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth: # Used by API clients (non-browser)
|
BearerAuth:
|
||||||
type: http
|
type: http
|
||||||
scheme: bearer
|
scheme: bearer
|
||||||
bearerFormat: JWT
|
bearerFormat: JWT
|
||||||
description: JWT authentication token provided in the Authorization header.
|
description: JWT authentication token provided in the Authorization header.
|
||||||
CookieAuth: # Used by the web application (browser)
|
CookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
in: cookie
|
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.
|
description: JWT authentication token provided via an HTTP-only cookie.
|
||||||
|
|
||||||
# Reusable Schemas
|
|
||||||
schemas:
|
schemas:
|
||||||
# --- User Schemas ---
|
# --- User Schemas ---
|
||||||
User:
|
User:
|
||||||
@ -80,7 +76,7 @@ components:
|
|||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
minLength: 6
|
minLength: 6
|
||||||
writeOnly: true # Password should not appear in responses
|
writeOnly: true
|
||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
- email
|
- email
|
||||||
@ -123,9 +119,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
minLength: 3
|
minLength: 3
|
||||||
maxLength: 50
|
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 Schemas ---
|
||||||
Tag:
|
Tag:
|
||||||
@ -146,7 +139,7 @@ components:
|
|||||||
description: Name of the tag (e.g., "Work", "Personal"). Must be unique per user.
|
description: Name of the tag (e.g., "Work", "Personal"). Must be unique per user.
|
||||||
color:
|
color:
|
||||||
type: string
|
type: string
|
||||||
format: hexcolor # Custom format hint, e.g., #FF5733
|
format: hexcolor
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Optional color associated with the tag.
|
description: Optional color associated with the tag.
|
||||||
icon:
|
icon:
|
||||||
@ -210,71 +203,68 @@ components:
|
|||||||
maxLength: 30
|
maxLength: 30
|
||||||
description: New icon identifier.
|
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 Schemas ---
|
||||||
Todo:
|
Todo:
|
||||||
type: object
|
type: object
|
||||||
description: Represents a Todo item.
|
description: Represents a Todo item.
|
||||||
properties:
|
properties:
|
||||||
id:
|
id: { type: string, format: uuid, readOnly: true }
|
||||||
type: string
|
userId: { type: string, format: uuid, readOnly: true }
|
||||||
format: uuid
|
title: { type: string }
|
||||||
readOnly: true
|
description: { type: string, nullable: true }
|
||||||
userId:
|
status: { type: string, enum: [pending, in-progress, completed], default: pending }
|
||||||
type: string
|
deadline: { type: string, format: date-time, nullable: true }
|
||||||
format: uuid
|
tagIds:
|
||||||
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
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items: { type: string, format: uuid }
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: List of IDs of Tags associated with this Todo.
|
|
||||||
default: []
|
default: []
|
||||||
attachments:
|
attachmentUrl: # <-- Changed from attachments array
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
type: string
|
||||||
description: List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints.
|
format: url
|
||||||
default: []
|
nullable: true
|
||||||
|
description: Publicly accessible URL of the attached image, if any.
|
||||||
subtasks:
|
subtasks:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items: { $ref: '#/components/schemas/Subtask' }
|
||||||
$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
|
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
default: []
|
||||||
|
createdAt: { type: string, format: date-time, readOnly: true }
|
||||||
|
updatedAt: { type: string, format: date-time, readOnly: true }
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- userId
|
- userId
|
||||||
- title
|
- title
|
||||||
- status
|
- status
|
||||||
- tagIds # <-- Added
|
- tagIds
|
||||||
- attachments
|
|
||||||
- createdAt
|
- createdAt
|
||||||
- updatedAt
|
- updatedAt
|
||||||
|
|
||||||
@ -296,7 +286,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
nullable: true
|
||||||
tagIds: # <-- Added
|
tagIds:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
@ -308,32 +298,15 @@ components:
|
|||||||
|
|
||||||
UpdateTodoRequest:
|
UpdateTodoRequest:
|
||||||
type: object
|
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:
|
properties:
|
||||||
title:
|
title: { type: string, minLength: 1 }
|
||||||
type: string
|
description: { type: string, nullable: true }
|
||||||
minLength: 1
|
status: { type: string, enum: [pending, in-progress, completed] }
|
||||||
description:
|
deadline: { type: string, format: date-time, nullable: true }
|
||||||
type: string
|
tagIds:
|
||||||
nullable: true
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
enum: [pending, in-progress, completed]
|
|
||||||
deadline:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
nullable: true
|
|
||||||
tagIds: # <-- Added
|
|
||||||
type: array
|
type: array
|
||||||
items:
|
items: { type: string, format: uuid }
|
||||||
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.
|
|
||||||
|
|
||||||
# --- Subtask Schemas ---
|
# --- Subtask Schemas ---
|
||||||
Subtask:
|
Subtask:
|
||||||
@ -392,34 +365,9 @@ components:
|
|||||||
completed:
|
completed:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
# --- File Upload Schemas ---
|
# --- File Upload Response Schema ---
|
||||||
FileUploadResponse:
|
FileUploadResponse: # This is the same as AttachmentInfo, could reuse definition with $ref
|
||||||
type: object
|
$ref: '#/components/schemas/AttachmentInfo'
|
||||||
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
|
|
||||||
|
|
||||||
# --- Error Schema ---
|
# --- Error Schema ---
|
||||||
Error:
|
Error:
|
||||||
@ -437,7 +385,6 @@ components:
|
|||||||
- code
|
- code
|
||||||
- message
|
- message
|
||||||
|
|
||||||
# Reusable Responses
|
|
||||||
responses:
|
responses:
|
||||||
BadRequest:
|
BadRequest:
|
||||||
description: Invalid input (e.g., validation error, missing fields, invalid tag ID).
|
description: Invalid input (e.g., validation error, missing fields, invalid tag ID).
|
||||||
@ -458,7 +405,7 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
NotFound:
|
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:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@ -476,13 +423,10 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
# Security Requirement applied globally or per-operation
|
|
||||||
# Most endpoints require either Bearer or Cookie auth.
|
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
- CookieAuth: []
|
- CookieAuth: []
|
||||||
|
|
||||||
# API Path Definitions
|
|
||||||
paths:
|
paths:
|
||||||
# --- Authentication Endpoints ---
|
# --- Authentication Endpoints ---
|
||||||
/auth/signup:
|
/auth/signup:
|
||||||
@ -490,7 +434,7 @@ paths:
|
|||||||
summary: Register a new user via email/password (API).
|
summary: Register a new user via email/password (API).
|
||||||
operationId: signupUserApi
|
operationId: signupUserApi
|
||||||
tags: [Auth]
|
tags: [Auth]
|
||||||
security: [] # No auth required to sign up
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
description: User details for registration.
|
description: User details for registration.
|
||||||
@ -508,7 +452,7 @@ paths:
|
|||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$ref: "#/components/responses/BadRequest"
|
||||||
"409":
|
"409":
|
||||||
$ref: "#/components/responses/Conflict" # e.g., Email or Username already exists
|
$ref: "#/components/responses/Conflict"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$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.
|
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
|
operationId: loginUserApi
|
||||||
tags: [Auth]
|
tags: [Auth]
|
||||||
security: [] # No auth required to log in
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
description: User credentials for login.
|
description: User credentials for login.
|
||||||
@ -534,24 +478,23 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/LoginResponse"
|
$ref: "#/components/schemas/LoginResponse"
|
||||||
headers:
|
headers:
|
||||||
Set-Cookie: # Indicate that a cookie might be set for browser clients
|
Set-Cookie:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Contains the JWT authentication cookie (e.g., `jwt_token=...; HttpOnly; Secure; Path=/; SameSite=Lax`)
|
description: Contains the JWT authentication cookie (e.g., `jwt_token=...; HttpOnly; Secure; Path=/; SameSite=Lax`)
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest"
|
$ref: "#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized" # Invalid credentials
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
|
|
||||||
/auth/logout: # Often useful to have an explicit logout
|
/auth/logout:
|
||||||
post:
|
post:
|
||||||
summary: Log out the current user.
|
summary: Log out the current user.
|
||||||
description: Invalidates the current session (e.g., clears the authentication cookie).
|
description: Invalidates the current session (e.g., clears the authentication cookie).
|
||||||
operationId: logoutUser
|
operationId: logoutUser
|
||||||
tags: [Auth]
|
tags: [Auth]
|
||||||
# Requires authentication to know *who* is logging out to clear their session/cookie
|
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
- CookieAuth: []
|
- CookieAuth: []
|
||||||
@ -559,12 +502,12 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Logout successful. No content returned.
|
description: Logout successful. No content returned.
|
||||||
headers:
|
headers:
|
||||||
Set-Cookie: # Indicate that the cookie is being cleared
|
Set-Cookie:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Clears the JWT authentication cookie (e.g., `jwt_token=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax`)
|
description: Clears the JWT authentication cookie (e.g., `jwt_token=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax`)
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized" # If not logged in initially
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$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.
|
description: Redirects the user's browser to Google's authentication page. Not a typical REST endpoint, part of the web flow.
|
||||||
operationId: initiateGoogleLogin
|
operationId: initiateGoogleLogin
|
||||||
tags: [Auth]
|
tags: [Auth]
|
||||||
security: [] # No API auth needed to start the flow
|
security: []
|
||||||
responses:
|
responses:
|
||||||
"302":
|
"302":
|
||||||
description: Redirect to Google's OAuth consent screen. The 'Location' header contains the redirect URL.
|
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).
|
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
|
operationId: handleGoogleCallback
|
||||||
tags: [Auth]
|
tags: [Auth]
|
||||||
security: [] # No API auth needed, Google provides auth code via query param
|
security: []
|
||||||
# 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.
|
|
||||||
responses:
|
responses:
|
||||||
"302":
|
"302":
|
||||||
description: Authentication successful. Redirects the user to the frontend application (e.g., '/dashboard'). Sets auth cookie.
|
description: Authentication successful. Redirects the user to the frontend application (e.g., '/dashboard'). Sets auth cookie.
|
||||||
@ -680,15 +610,15 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/User"
|
$ref: "#/components/schemas/User"
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest" # Validation error
|
$ref: "#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"409":
|
"409":
|
||||||
$ref: "#/components/responses/Conflict" # e.g. Username already taken
|
$ref: "#/components/responses/Conflict"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
|
|
||||||
# --- Tag Endpoints --- <-- New Section
|
# --- Tag Endpoints ---
|
||||||
/tags:
|
/tags:
|
||||||
get:
|
get:
|
||||||
summary: List all tags created by the current user.
|
summary: List all tags created by the current user.
|
||||||
@ -732,11 +662,11 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Tag'
|
$ref: '#/components/schemas/Tag'
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest" # Validation error
|
$ref: "#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"409":
|
"409":
|
||||||
$ref: "#/components/responses/Conflict" # Tag name already exists for this user
|
$ref: "#/components/responses/Conflict"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
|
|
||||||
@ -766,7 +696,7 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User does not own this tag
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
@ -793,15 +723,15 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Tag'
|
$ref: '#/components/schemas/Tag'
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest" # Validation error
|
$ref: "#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User does not own this tag
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"409":
|
"409":
|
||||||
$ref: "#/components/responses/Conflict" # New tag name already exists for this user
|
$ref: "#/components/responses/Conflict"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
delete:
|
delete:
|
||||||
@ -818,13 +748,12 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User does not own this tag
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
|
|
||||||
|
|
||||||
# --- Todo Endpoints ---
|
# --- Todo Endpoints ---
|
||||||
/todos:
|
/todos:
|
||||||
get:
|
get:
|
||||||
@ -835,59 +764,14 @@ paths:
|
|||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
- CookieAuth: []
|
- CookieAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
- name: status
|
- { name: status, in: query, required: false, schema: { type: string, enum: [pending, in-progress, completed] } }
|
||||||
in: query
|
- { name: tagId, in: query, required: false, schema: { type: string, format: uuid } }
|
||||||
required: false
|
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, default: 20 } }
|
||||||
schema:
|
- { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } }
|
||||||
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.
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: A list of Todo items matching the criteria.
|
description: A list of Todo items.
|
||||||
content:
|
content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Todo" } } } }
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/Todo"
|
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"500":
|
"500":
|
||||||
@ -896,57 +780,35 @@ paths:
|
|||||||
summary: Create a new Todo item.
|
summary: Create a new Todo item.
|
||||||
operationId: createTodo
|
operationId: createTodo
|
||||||
tags: [Todos]
|
tags: [Todos]
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
- CookieAuth: []
|
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
description: Todo item details to create, optionally including Tag IDs.
|
content: { application/json: { schema: { $ref: "#/components/schemas/CreateTodoRequest" } } }
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/CreateTodoRequest" # Now includes tagIds
|
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Todo item created successfully. Returns the new Todo.
|
description: Todo item created successfully.
|
||||||
content:
|
content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/Todo"
|
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided
|
$ref: "#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
|
|
||||||
/todos/{todoId}:
|
/todos/{todoId}:
|
||||||
parameters: # Parameter applicable to all methods for this path
|
parameters:
|
||||||
- name: todoId
|
- { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: ID of the Todo item.
|
|
||||||
get:
|
get:
|
||||||
summary: Get a specific Todo item by ID.
|
summary: Get a specific Todo item by ID.
|
||||||
operationId: getTodoById
|
operationId: getTodoById
|
||||||
tags: [Todos]
|
tags: [Todos]
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
- CookieAuth: []
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: The requested Todo item.
|
description: The requested Todo item.
|
||||||
content:
|
content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/Todo" # Now includes tagIds
|
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
@ -955,29 +817,19 @@ paths:
|
|||||||
summary: Update a specific Todo item by ID.
|
summary: Update a specific Todo item by ID.
|
||||||
operationId: updateTodoById
|
operationId: updateTodoById
|
||||||
tags: [Todos]
|
tags: [Todos]
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
- CookieAuth: []
|
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
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" } } }
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/UpdateTodoRequest" # Now includes tagIds
|
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Todo item updated successfully. Returns the updated Todo.
|
description: Todo item updated successfully.
|
||||||
content:
|
content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/Todo"
|
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided
|
$ref: "#/components/responses/BadRequest"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
@ -995,7 +847,7 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
@ -1004,62 +856,44 @@ paths:
|
|||||||
# --- Attachment Endpoints ---
|
# --- Attachment Endpoints ---
|
||||||
/todos/{todoId}/attachments:
|
/todos/{todoId}/attachments:
|
||||||
parameters:
|
parameters:
|
||||||
- name: todoId
|
- { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: ID of the Todo item to attach the file to.
|
|
||||||
post:
|
post:
|
||||||
summary: Upload a file and attach it to a Todo item.
|
summary: Upload or replace the image attachment for a Todo item.
|
||||||
operationId: uploadTodoAttachment
|
operationId: uploadOrReplaceTodoAttachment # Renamed for clarity
|
||||||
tags: [Attachments, Todos]
|
tags: [Attachments, Todos]
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
- CookieAuth: []
|
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
description: The file to upload.
|
description: The image file to upload.
|
||||||
content:
|
content:
|
||||||
multipart/form-data:
|
multipart/form-data:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties: { file: { type: string, format: binary } }
|
||||||
file: # Name of the form field for the file
|
required: [file]
|
||||||
type: string
|
|
||||||
format: binary
|
|
||||||
required:
|
|
||||||
- file
|
|
||||||
# You might add examples or encoding details here if needed
|
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201": # Use 201 Created (or 200 OK if replacing)
|
||||||
description: File uploaded and attached successfully. Returns file details. The Todo's `attachments` array is updated server-side.
|
description: Image uploaded/replaced successfully. Returns file details.
|
||||||
content:
|
content: { application/json: { schema: { $ref: '#/components/schemas/FileUploadResponse' } } } # Reusing this schema
|
||||||
application/json:
|
"400": { $ref: "#/components/responses/BadRequest" } # Invalid file type, size limit etc.
|
||||||
schema:
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||||
$ref: '#/components/schemas/FileUploadResponse'
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
"400":
|
"404": { $ref: "#/components/responses/NotFound" } # Todo not found
|
||||||
$ref: "#/components/responses/BadRequest" # e.g., No file, size limit exceeded, invalid file type
|
"500": { $ref: "#/components/responses/InternalServerError" }
|
||||||
"401":
|
delete:
|
||||||
$ref: "#/components/responses/Unauthorized"
|
summary: Delete the image attachment from a Todo item.
|
||||||
"403":
|
operationId: deleteTodoAttachment # Reused name is fine
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
tags: [Attachments, Todos]
|
||||||
"404":
|
responses:
|
||||||
$ref: "#/components/responses/NotFound" # Todo not found
|
"204": { description: Attachment deleted successfully. }
|
||||||
"500":
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||||
$ref: "#/components/responses/InternalServerError" # File storage error, etc.
|
"403": { $ref: "#/components/responses/Forbidden" }
|
||||||
|
"404": { $ref: "#/components/responses/NotFound" } # Todo or attachment not found
|
||||||
|
"500": { $ref: "#/components/responses/InternalServerError" }
|
||||||
|
|
||||||
# --- Subtask Endpoints ---
|
# --- Subtask Endpoints ---
|
||||||
/todos/{todoId}/subtasks:
|
/todos/{todoId}/subtasks:
|
||||||
parameters:
|
parameters:
|
||||||
- name: todoId
|
- { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: ID of the parent Todo item.
|
|
||||||
get:
|
get:
|
||||||
summary: List all subtasks for a specific Todo item.
|
summary: List all subtasks for a specific Todo item.
|
||||||
operationId: listSubtasksForTodo
|
operationId: listSubtasksForTodo
|
||||||
@ -1079,9 +913,9 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound" # Parent Todo not found
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
post:
|
post:
|
||||||
@ -1110,9 +944,9 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound" # Parent Todo not found
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
|
|
||||||
@ -1158,9 +992,9 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound" # Todo or Subtask not found
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
delete:
|
delete:
|
||||||
@ -1176,8 +1010,8 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
$ref: "#/components/responses/Forbidden"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound" # Todo or Subtask not found
|
$ref: "#/components/responses/NotFound"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalServerError"
|
$ref: "#/components/responses/InternalServerError"
|
||||||
@ -100,4 +100,5 @@ export const Icons = {
|
|||||||
loader: IconLoader,
|
loader: IconLoader,
|
||||||
circle: IconCircle,
|
circle: IconCircle,
|
||||||
moreVertical: IconDotsVertical,
|
moreVertical: IconDotsVertical,
|
||||||
|
file: IconFile,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
// import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@ -15,45 +15,45 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
import { Badge } from "@/components/ui/badge";
|
// import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [notificationCount, setNotificationCount] = useState(3);
|
// const [notificationCount, setNotificationCount] = useState(3);
|
||||||
|
|
||||||
const notifications = [
|
// const notifications = [
|
||||||
{
|
// {
|
||||||
id: 1,
|
// id: 1,
|
||||||
title: "New task assigned",
|
// title: "New task assigned",
|
||||||
description: "You have been assigned a new task",
|
// description: "You have been assigned a new task",
|
||||||
time: "5 minutes ago",
|
// time: "5 minutes ago",
|
||||||
read: false,
|
// read: false,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 2,
|
// id: 2,
|
||||||
title: "Task completed",
|
// title: "Task completed",
|
||||||
description: "Your task 'Update documentation' has been completed",
|
// description: "Your task 'Update documentation' has been completed",
|
||||||
time: "1 hour ago",
|
// time: "1 hour ago",
|
||||||
read: false,
|
// read: false,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 3,
|
// id: 3,
|
||||||
title: "Meeting reminder",
|
// title: "Meeting reminder",
|
||||||
description: "Team meeting starts in 30 minutes",
|
// description: "Team meeting starts in 30 minutes",
|
||||||
time: "2 hours ago",
|
// time: "2 hours ago",
|
||||||
read: false,
|
// read: false,
|
||||||
},
|
// },
|
||||||
];
|
// ];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const markAsRead = (id: number) => {
|
// const markAsRead = (id: number) => {
|
||||||
setNotificationCount(Math.max(0, notificationCount - 1));
|
// setNotificationCount(Math.max(0, notificationCount - 1));
|
||||||
};
|
// };
|
||||||
|
|
||||||
const markAllAsRead = () => {
|
// const markAllAsRead = () => {
|
||||||
setNotificationCount(0);
|
// setNotificationCount(0);
|
||||||
};
|
// };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
@ -110,7 +110,7 @@ export function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
{/* Notifications Dropdown */}
|
{/* Notifications Dropdown */}
|
||||||
<DropdownMenu>
|
{/* <DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -173,7 +173,7 @@ export function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu> */}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@ -1,16 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import {
|
import { Card, CardContent, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -26,6 +20,7 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
import { TodoForm } from "@/components/todo-form";
|
import { TodoForm } from "@/components/todo-form";
|
||||||
import { Icons } from "@/components/icons";
|
import { Icons } from "@/components/icons";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -35,8 +30,9 @@ import Image from "next/image";
|
|||||||
interface TodoCardProps {
|
interface TodoCardProps {
|
||||||
todo: Todo;
|
todo: Todo;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
onUpdate: (todo: Partial<Todo>) => void;
|
onUpdate: (todo: Partial<Todo>) => Promise<void>;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onAttachmentsChanged?: (attachments: string[]) => void;
|
||||||
isDraggable?: boolean;
|
isDraggable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +41,7 @@ export function TodoCard({
|
|||||||
tags,
|
tags,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onAttachmentsChanged,
|
||||||
isDraggable = false,
|
isDraggable = false,
|
||||||
}: TodoCardProps) {
|
}: TodoCardProps) {
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
@ -69,7 +66,7 @@ export function TodoCard({
|
|||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Style helpers
|
// --- Helper Functions ---
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "pending":
|
case "pending":
|
||||||
@ -82,63 +79,80 @@ export function TodoCard({
|
|||||||
return "border-l-4 border-l-slate-400";
|
return "border-l-4 border-l-slate-400";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// const getStatusIcon = (status: string) => {
|
||||||
const getStatusIcon = (status: string) => {
|
// switch (status) {
|
||||||
switch (status) {
|
// case "pending":
|
||||||
case "pending":
|
// return <Icons.clock className="h-5 w-5 text-amber-500" />;
|
||||||
return <Icons.clock className="h-5 w-5 text-amber-500" />;
|
// case "in-progress":
|
||||||
case "in-progress":
|
// return <Icons.loader className="h-5 w-5 text-sky-500" />;
|
||||||
return <Icons.loader className="h-5 w-5 text-sky-500" />;
|
// case "completed":
|
||||||
case "completed":
|
// return <Icons.checkSquare className="h-5 w-5 text-emerald-500" />;
|
||||||
return <Icons.checkSquare className="h-5 w-5 text-emerald-500" />;
|
// default:
|
||||||
default:
|
// return <Icons.circle className="h-5 w-5 text-slate-400" />;
|
||||||
return <Icons.circle className="h-5 w-5 text-slate-400" />;
|
// }
|
||||||
}
|
// };
|
||||||
};
|
|
||||||
|
|
||||||
const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id));
|
|
||||||
const hasImage = !!todo.image;
|
|
||||||
const hasAttachments = todo.attachments && todo.attachments.length > 0;
|
|
||||||
const hasSubtasks = todo.subtasks && todo.subtasks.length > 0;
|
|
||||||
const completedSubtasks = todo.subtasks
|
|
||||||
? todo.subtasks.filter((subtask) => subtask.completed).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const handleStatusToggle = () => {
|
|
||||||
const newStatus = todo.status === "completed" ? "pending" : "completed";
|
|
||||||
onUpdate({ status: newStatus });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString?: string | null) => {
|
const formatDate = (dateString?: string | null) => {
|
||||||
if (!dateString) return "";
|
if (!dateString) return "";
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const todoTags = tags.filter((tag) => todo.tagIds?.includes(tag.id));
|
||||||
|
const hasAttachments = !!todo.attachmentUrl;
|
||||||
|
const hasSubtasks = todo.subtasks && todo.subtasks.length > 0;
|
||||||
|
const completedSubtasks =
|
||||||
|
todo.subtasks?.filter((s) => s.completed).length ?? 0;
|
||||||
|
|
||||||
|
// --- Event Handlers ---
|
||||||
|
const handleStatusToggle = () => {
|
||||||
|
const newStatus = todo.status === "completed" ? "pending" : "completed";
|
||||||
|
onUpdate({ status: newStatus });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Rendering ---
|
||||||
|
const coverImage =
|
||||||
|
todo.attachmentUrl &&
|
||||||
|
todo.attachmentUrl.match(/\.(jpg|jpeg|png|gif|webp)$/i)
|
||||||
|
? todo.attachmentUrl
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors",
|
"transition-shadow duration-150 ease-out",
|
||||||
getStatusColor(todo.status),
|
getStatusColor(todo.status),
|
||||||
"shadow-sm min-w-[220px] max-w-[420px]",
|
"shadow-sm hover:shadow-md min-w-[220px] max-w-[420px] bg-card",
|
||||||
isDraggable ? "cursor-grab active:cursor-grabbing" : "",
|
isDraggable ? "cursor-grab active:cursor-grabbing" : "",
|
||||||
isDragging ? "shadow-lg" : ""
|
isDragging
|
||||||
|
? "shadow-lg ring-2 ring-primary ring-opacity-50 scale-105 z-10"
|
||||||
|
: ""
|
||||||
)}
|
)}
|
||||||
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
||||||
|
onClick={() => !isDraggable && setIsViewDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4 flex gap-4">
|
<CardContent className="p-3">
|
||||||
{/* Left icon, like notification card */}
|
{/* Optional Cover Image */}
|
||||||
<div className="flex-shrink-0 mt-1">{getStatusIcon(todo.status)}</div>
|
{coverImage && (
|
||||||
{/* Main content */}
|
<div className="relative h-24 w-full mb-2 rounded overflow-hidden">
|
||||||
<div className="flex-grow">
|
<Image
|
||||||
<CardHeader className="p-0">
|
src={coverImage || "/placeholder.svg"}
|
||||||
<div className="flex items-center justify-between">
|
alt="Todo Attachment"
|
||||||
|
fill
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
sizes="(max-width: 640px) 100vw, 300px"
|
||||||
|
className="bg-muted"
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Title and Menu */}
|
||||||
|
<div className="flex items-start justify-between mb-1">
|
||||||
<CardTitle
|
<CardTitle
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-base",
|
"text-sm font-medium leading-snug pr-2",
|
||||||
todo.status === "completed" &&
|
todo.status === "completed" &&
|
||||||
"line-through text-muted-foreground"
|
"line-through text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
@ -146,62 +160,43 @@ export function TodoCard({
|
|||||||
{todo.title}
|
{todo.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{!isDraggable && (
|
{!isDraggable && (
|
||||||
<DropdownMenu>
|
<DropdownMenu
|
||||||
|
onOpenChange={(open) => open && setIsViewDialogOpen(false)}
|
||||||
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-6 w-6 flex-shrink-0 -mt-1 -mr-1"
|
||||||
aria-label="More actions"
|
|
||||||
>
|
>
|
||||||
<Icons.moreVertical className="h-4 w-4" />
|
<Icons.moreVertical className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent
|
||||||
<DropdownMenuItem
|
align="end"
|
||||||
onClick={() => setIsViewDialogOpen(true)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Icons.eye className="h-4 w-4 mr-2" />
|
<DropdownMenuItem onClick={() => setIsViewDialogOpen(true)}>
|
||||||
View details
|
<Icons.eye className="h-3.5 w-3.5 mr-2" /> View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>
|
||||||
onClick={() => setIsEditDialogOpen(true)}
|
<Icons.edit className="h-3.5 w-3.5 mr-2" /> Edit
|
||||||
>
|
|
||||||
<Icons.edit className="h-4 w-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||||
onClick={onDelete}
|
<Icons.trash className="h-3.5 w-3.5 mr-2" /> Delete
|
||||||
className="text-red-600"
|
|
||||||
>
|
|
||||||
<Icons.trash className="h-4 w-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Actions and deadline on a new line */}
|
{/* Description (optional, truncated) */}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
{todo.description && (
|
||||||
{todo.deadline && (
|
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2">
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
<Icons.calendar className="h-3 w-3" />
|
|
||||||
{formatDate(todo.deadline)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardDescription
|
|
||||||
className={cn(
|
|
||||||
"mt-1",
|
|
||||||
todo.status === "completed" &&
|
|
||||||
"line-through text-muted-foreground/70"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{todo.description}
|
{todo.description}
|
||||||
</CardDescription>
|
</p>
|
||||||
{/* Tags and indicators */}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
{/* Badges and Indicators */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 text-[10px] mb-1.5">
|
||||||
{todoTags.map((tag) => (
|
{todoTags.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
@ -223,35 +218,37 @@ export function TodoCard({
|
|||||||
)}
|
)}
|
||||||
{hasAttachments && (
|
{hasAttachments && (
|
||||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||||
<Icons.paperclip className="h-3 w-3" />
|
<Icons.paperclip className="h-3 w-3" />1
|
||||||
{todo.attachments.length}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasImage && (
|
{todo.deadline && (
|
||||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||||
<Icons.image className="h-3 w-3" />
|
<Icons.calendar className="h-3 w-3" />
|
||||||
|
{formatDate(todo.deadline)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Bottom row: created date and status toggle */}
|
{/* Bottom Row: Created Date & Status Toggle */}
|
||||||
<div className="flex justify-between items-center mt-3">
|
<div className="flex justify-between items-center mt-1">
|
||||||
<span className="text-[10px] text-muted-foreground/70">
|
<span className="text-[10px] text-muted-foreground/70">
|
||||||
{todo.createdAt ? formatDate(todo.createdAt) : ""}
|
{todo.createdAt ? formatDate(todo.createdAt) : ""}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant={todo.status === "completed" ? "ghost" : "outline"}
|
variant={todo.status === "completed" ? "ghost" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 text-[10px] px-2 py-0 rounded-full"
|
className="h-5 px-1.5 py-0 rounded-full"
|
||||||
onClick={handleStatusToggle}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStatusToggle();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{todo.status === "completed" ? (
|
{todo.status === "completed" ? (
|
||||||
<Icons.x className="h-3 w-3" />
|
<Icons.x className="h-2.5 w-2.5" />
|
||||||
) : (
|
) : (
|
||||||
<Icons.check className="h-3 w-3" />
|
<Icons.check className="h-2.5 w-2.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -261,17 +258,17 @@ export function TodoCard({
|
|||||||
<DialogHeader className="space-y-1">
|
<DialogHeader className="space-y-1">
|
||||||
<DialogTitle className="text-xl">Edit Todo</DialogTitle>
|
<DialogTitle className="text-xl">Edit Todo</DialogTitle>
|
||||||
<DialogDescription className="text-sm">
|
<DialogDescription className="text-sm">
|
||||||
Make changes to your task and save when you're done
|
Make changes to your task, add attachments, and save.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<TodoForm
|
<TodoForm
|
||||||
todo={todo}
|
todo={todo}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
onSubmit={(updatedTodo) => {
|
onSubmit={async (updatedTodoData) => {
|
||||||
onUpdate(updatedTodo);
|
await onUpdate(updatedTodoData);
|
||||||
setIsEditDialogOpen(false);
|
setIsEditDialogOpen(false);
|
||||||
toast.success("Todo updated successfully");
|
|
||||||
}}
|
}}
|
||||||
|
onAttachmentsChanged={onAttachmentsChanged}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -283,7 +280,7 @@ export function TodoCard({
|
|||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 capitalize font-medium text-sm",
|
"px-2.5 py-1 capitalize font-medium text-xs",
|
||||||
todo.status === "pending"
|
todo.status === "pending"
|
||||||
? "bg-amber-50 text-amber-700"
|
? "bg-amber-50 text-amber-700"
|
||||||
: todo.status === "in-progress"
|
: todo.status === "in-progress"
|
||||||
@ -295,14 +292,8 @@ export function TodoCard({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-2.5 h-2.5 rounded-full mr-1.5",
|
"w-2 h-2 rounded-full mr-1.5",
|
||||||
todo.status === "pending"
|
getStatusColor(todo.status).replace("border-l-4 ", "bg-")
|
||||||
? "bg-amber-500"
|
|
||||||
: todo.status === "in-progress"
|
|
||||||
? "bg-sky-500"
|
|
||||||
: todo.status === "completed"
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: "bg-slate-400"
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{todo.status.replace("-", " ")}
|
{todo.status.replace("-", " ")}
|
||||||
@ -324,10 +315,11 @@ export function TodoCard({
|
|||||||
Created {formatDate(todo.createdAt)}
|
Created {formatDate(todo.createdAt)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{hasImage && (
|
{/* Cover Image */}
|
||||||
<div className="w-full h-48 overflow-hidden relative">
|
{coverImage && (
|
||||||
|
<div className="w-full h-48 overflow-hidden relative bg-muted">
|
||||||
<Image
|
<Image
|
||||||
src={todo.image || "/placeholder.svg?height=192&width=450"}
|
src={coverImage || "/placeholder.svg"}
|
||||||
alt={todo.title}
|
alt={todo.title}
|
||||||
fill
|
fill
|
||||||
style={{ objectFit: "cover" }}
|
style={{ objectFit: "cover" }}
|
||||||
@ -336,15 +328,20 @@ export function TodoCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="px-6 py-4 space-y-4">
|
{/* Content Section */}
|
||||||
|
<div className="px-6 py-4 space-y-4 max-h-[50vh] overflow-y-auto">
|
||||||
|
{/* Description */}
|
||||||
{todo.description && (
|
{todo.description && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{todo.description}
|
{todo.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Tags */}
|
||||||
{todoTags.length > 0 && (
|
{todoTags.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-medium mb-1.5">Tags</h4>
|
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
|
TAGS
|
||||||
|
</h4>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{todoTags.map((tag) => (
|
{todoTags.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
@ -362,26 +359,11 @@ export function TodoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasAttachments && (
|
{/* Subtasks */}
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-medium mb-1.5">Attachments</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{todo.attachments.map((a, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
<Icons.paperclip className="h-3.5 w-3.5" />
|
|
||||||
<span>{a}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasSubtasks && (
|
{hasSubtasks && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-medium mb-1.5">
|
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
Subtasks ({completedSubtasks}/{todo.subtasks.length})
|
SUBTASKS ({completedSubtasks}/{todo.subtasks.length})
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-1.5 text-sm">
|
<ul className="space-y-1.5 text-sm">
|
||||||
{todo.subtasks.map((subtask) => (
|
{todo.subtasks.map((subtask) => (
|
||||||
@ -413,8 +395,42 @@ export function TodoCard({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Attachments */}
|
||||||
|
{hasAttachments && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
|
ATTACHMENT
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{todo.attachmentUrl && (
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-md border bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className="w-8 h-8 rounded overflow-hidden flex-shrink-0 bg-background">
|
||||||
|
<Image
|
||||||
|
src={todo.attachmentUrl}
|
||||||
|
alt={todo.title}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
<a
|
||||||
|
href={todo.attachmentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="truncate text-xs text-blue-600 underline"
|
||||||
|
>
|
||||||
|
View Image
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="border-t px-6 py-3 bg-muted/30 flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -429,7 +445,7 @@ export function TodoCard({
|
|||||||
setIsEditDialogOpen(true);
|
setIsEditDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit
|
<Icons.edit className="h-3.5 w-3.5 mr-1.5" /> Edit Todo
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -16,25 +17,33 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { MultiSelect } from "@/components/multi-select";
|
import { MultiSelect } from "@/components/multi-select";
|
||||||
import { Icons } from "@/components/icons";
|
|
||||||
import type { Todo, Tag } from "@/services/api-types";
|
import type { Todo, Tag } from "@/services/api-types";
|
||||||
|
import { Progress } from "./ui/progress";
|
||||||
|
|
||||||
interface TodoFormProps {
|
interface TodoFormProps {
|
||||||
todo?: Todo;
|
todo?: Todo;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
onSubmit: (todo: Partial<Todo>) => void;
|
onSubmit: (todo: Partial<Todo>) => Promise<void>;
|
||||||
|
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<Partial<Todo>>({
|
const [formData, setFormData] = useState<Partial<Todo>>({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
deadline: undefined,
|
deadline: undefined,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
image: null,
|
attachmentUrl: null,
|
||||||
});
|
});
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (todo) {
|
if (todo) {
|
||||||
@ -44,12 +53,17 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
|
|||||||
status: todo.status,
|
status: todo.status,
|
||||||
deadline: todo.deadline,
|
deadline: todo.deadline,
|
||||||
tagIds: todo.tagIds || [],
|
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]);
|
}, [todo]);
|
||||||
|
|
||||||
@ -68,29 +82,85 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
|
|||||||
setFormData((prev) => ({ ...prev, tagIds: selected }));
|
setFormData((prev) => ({ ...prev, tagIds: selected }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Upload new attachment
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
// 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];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
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
|
setIsUploading(true);
|
||||||
// For now, we'll create a local object URL
|
setUploadProgress(0);
|
||||||
const imageUrl = URL.createObjectURL(file);
|
|
||||||
setImagePreview(imageUrl);
|
// Simulate progress for demo – replace if backend supports it
|
||||||
setFormData((prev) => ({ ...prev, image: imageUrl }));
|
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 = () => {
|
// Remove an existing attachment
|
||||||
setImagePreview(null);
|
const handleRemoveAttachment = async (attachmentId: string) => {
|
||||||
setFormData((prev) => ({ ...prev, image: null }));
|
// 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();
|
e.preventDefault();
|
||||||
onSubmit(formData);
|
await onSubmit(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Title, Description, Status, Deadline, Tags... */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title</Label>
|
<Label htmlFor="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -150,46 +220,50 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
|
|||||||
placeholder="Select tags"
|
placeholder="Select tags"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Attachment Section - Only shown when editing an existing todo */}
|
||||||
|
{todo && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="image">Image (optional)</Label>
|
<Label htmlFor="attachments">Attachments</Label>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
<Input
|
||||||
id="image"
|
id="file"
|
||||||
name="image"
|
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleImageChange}
|
multiple={false}
|
||||||
className="flex-1"
|
onChange={handleFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
{imagePreview && (
|
{isUploading && (
|
||||||
<Button
|
<Progress value={uploadProgress} className="w-full h-2" />
|
||||||
type="button"
|
)}
|
||||||
variant="outline"
|
</div>
|
||||||
size="icon"
|
)}
|
||||||
onClick={handleRemoveImage}
|
{formData.attachmentUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ul>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={formData.attachmentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 underline"
|
||||||
>
|
>
|
||||||
<Icons.trash className="h-4 w-4" />
|
View Attachment
|
||||||
<span className="sr-only">Remove image</span>
|
</a>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveAttachment("")}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
{imagePreview && (
|
|
||||||
<div className="mt-2 relative w-full h-32 rounded-md overflow-hidden border">
|
|
||||||
<Image
|
|
||||||
src={imagePreview || "/placeholder.svg"}
|
|
||||||
alt="Preview"
|
|
||||||
width={400}
|
|
||||||
height={128}
|
|
||||||
className="w-full h-full object-cover rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<Button type="submit" className="w-full h-10" disabled={isUploading}>
|
||||||
<Button
|
{isUploading ? "Uploading..." : todo ? "Update Todo" : "Create Todo"}
|
||||||
type="submit"
|
|
||||||
className="w-full bg-[#FF5A5F] hover:bg-[#FF5A5F]/90"
|
|
||||||
>
|
|
||||||
{todo ? "Update" : "Create"} Todo
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,56 +1,116 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner";
|
||||||
import { useSortable } from "@dnd-kit/sortable"
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import Image from "next/image"; // Import Image
|
||||||
import { Button } from "@/components/ui/button"
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { deleteAttachment } from "@/services/api-attachments";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { TodoForm } from "@/components/todo-form"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Icons } from "@/components/icons"
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils"
|
import {
|
||||||
import type { Todo, Tag } from "@/services/api-types"
|
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 {
|
interface TodoRowProps {
|
||||||
todo: Todo
|
todo: Todo;
|
||||||
tags: Tag[]
|
tags: Tag[];
|
||||||
onUpdate: (todo: Partial<Todo>) => void
|
onUpdate: (todo: Partial<Todo>) => Promise<void>; // Make async
|
||||||
onDelete: () => void
|
onDelete: () => void;
|
||||||
isDraggable?: boolean
|
onAttachmentsChanged?: (attachments: AttachmentInfo[]) => void; // Add callback
|
||||||
|
isDraggable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }: TodoRowProps) {
|
export function TodoRow({
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
todo,
|
||||||
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
|
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,
|
id: todo.id,
|
||||||
disabled: !isDraggable,
|
disabled: !isDraggable,
|
||||||
})
|
});
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
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 handleStatusToggle = () => {
|
||||||
const newStatus = todo.status === "completed" ? "pending" : "completed"
|
const newStatus = todo.status === "completed" ? "pending" : "completed";
|
||||||
onUpdate({ status: newStatus })
|
onUpdate({ status: newStatus });
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatDate = (dateString?: string | null) => {
|
const formatDate = (dateString?: string | null) => {
|
||||||
if (!dateString) return null
|
if (!dateString) return null;
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString()
|
return date.toLocaleDateString();
|
||||||
}
|
};
|
||||||
|
|
||||||
// Check if todo has image or attachments
|
const handleDeleteAttachment = async (attachmentId: string) => {
|
||||||
const hasAttachments = todo.attachments && todo.attachments.length > 0
|
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 (
|
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",
|
"group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 rounded-md transition-colors",
|
||||||
todo.status === "completed" ? "text-muted-foreground" : "",
|
todo.status === "completed" ? "text-muted-foreground" : "",
|
||||||
isDraggable ? "cursor-grab active:cursor-grabbing" : "",
|
isDraggable ? "cursor-grab active:cursor-grabbing" : "",
|
||||||
isDragging ? "shadow-lg bg-muted" : "",
|
isDragging ? "shadow-lg bg-muted" : ""
|
||||||
)}
|
)}
|
||||||
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={todo.status === "completed"}
|
checked={todo.status === "completed"}
|
||||||
onCheckedChange={() => handleStatusToggle()}
|
onCheckedChange={() => handleStatusToggle()}
|
||||||
className="h-5 w-5 rounded-full"
|
className="h-5 w-5 rounded-full flex-shrink-0" // Added flex-shrink-0
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => setIsViewDialogOpen(true)}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
{/* Make main area clickable */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn("text-sm font-medium truncate", todo.status === "completed" ? "line-through" : "")}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium truncate",
|
||||||
|
todo.status === "completed" ? "line-through" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
{todo.title}
|
{todo.title}
|
||||||
</span>
|
</span>
|
||||||
{todo.image && <Icons.image className="h-3.5 w-3.5 text-muted-foreground" />}
|
{/* Indicators */}
|
||||||
{hasAttachments && <Icons.paperclip className="h-3.5 w-3.5 text-muted-foreground" />}
|
<div className="flex items-center gap-1">
|
||||||
|
{hasAttachments && (
|
||||||
|
<Icons.paperclip className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{/* Add other indicators like subtasks if needed */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{todo.description && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{todo.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{todo.description && <p className="text-xs text-muted-foreground truncate">{todo.description}</p>}
|
{/* Right-aligned section */}
|
||||||
</div>
|
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
|
||||||
|
{" "}
|
||||||
<div className="flex items-center gap-2">
|
{/* Added ml-auto and flex-shrink-0 */}
|
||||||
|
{/* Tags */}
|
||||||
{todoTags.length > 0 && (
|
{todoTags.length > 0 && (
|
||||||
<div className="flex gap-1">
|
<div className="hidden sm:flex gap-1">
|
||||||
|
{" "}
|
||||||
|
{/* Hide tags on very small screens */}
|
||||||
{todoTags.slice(0, 2).map((tag) => (
|
{todoTags.slice(0, 2).map((tag) => (
|
||||||
<div
|
<div
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="w-2 h-2 rounded-full"
|
className="w-2 h-2 rounded-full"
|
||||||
style={{ backgroundColor: tag.color || "#FF5A5F" }}
|
style={{ backgroundColor: tag.color || "#FF5A5F" }}
|
||||||
|
title={tag.name}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{todoTags.length > 2 && <span className="text-xs text-muted-foreground">+{todoTags.length - 2}</span>}
|
{todoTags.length > 2 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
+{todoTags.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Deadline */}
|
||||||
{todo.deadline && (
|
{todo.deadline && (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(todo.deadline)}</span>
|
<span className="text-xs text-muted-foreground whitespace-nowrap hidden md:inline">
|
||||||
|
{formatDate(todo.deadline)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Action Buttons (Appear on Hover) */}
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||||
<Button variant="ghost" size="icon" onClick={() => setIsViewDialogOpen(true)} className="h-7 w-7">
|
<Button
|
||||||
<Icons.eye className="h-3.5 w-3.5" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsViewDialogOpen(true)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Icons.eye className="h-3.5 w-3.5" />{" "}
|
||||||
<span className="sr-only">View</span>
|
<span className="sr-only">View</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => setIsEditDialogOpen(true)} className="h-7 w-7">
|
<Button
|
||||||
<Icons.edit className="h-3.5 w-3.5" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsEditDialogOpen(true)}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Icons.edit className="h-3.5 w-3.5" />{" "}
|
||||||
<span className="sr-only">Edit</span>
|
<span className="sr-only">Edit</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={onDelete} className="h-7 w-7">
|
{/* Delete Confirmation */}
|
||||||
<Icons.trash className="h-3.5 w-3.5" />
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Icons.trash className="h-3.5 w-3.5" />{" "}
|
||||||
<span className="sr-only">Delete</span>
|
<span className="sr-only">Delete</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Todo?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{todo.title}"?
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Todo</DialogTitle>
|
<DialogTitle>Edit Todo</DialogTitle>
|
||||||
<DialogDescription>Update your todo details</DialogDescription>
|
<DialogDescription>
|
||||||
|
Update your todo details and attachments.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<TodoForm
|
<TodoForm
|
||||||
todo={todo}
|
todo={todo}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
onSubmit={(updatedTodo) => {
|
onSubmit={async (updatedTodoData) => {
|
||||||
onUpdate(updatedTodo)
|
await onUpdate(updatedTodoData);
|
||||||
setIsEditDialogOpen(false)
|
setIsEditDialogOpen(false);
|
||||||
toast.success("Todo updated successfully")
|
}}
|
||||||
|
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 }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* View Dialog */}
|
||||||
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
|
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
{/* Re-use the View Dialog structure from TodoCard, adapting as needed */}
|
||||||
<DialogHeader>
|
<DialogContent className="sm:max-w-[450px] p-0 overflow-hidden">
|
||||||
<DialogTitle>{todo.title}</DialogTitle>
|
<DialogHeader className="px-6 pt-6 pb-2">
|
||||||
<DialogDescription>Created on {new Date(todo.createdAt).toLocaleDateString()}</DialogDescription>
|
{/* ... Header content (status badge, title, etc.) ... */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{/* Status Badge */}
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 capitalize font-medium text-xs",
|
||||||
|
todo.status === "pending"
|
||||||
|
? "bg-amber-50 text-amber-700"
|
||||||
|
: todo.status === "in-progress"
|
||||||
|
? "bg-sky-50 text-sky-700"
|
||||||
|
: todo.status === "completed"
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "bg-slate-50 text-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full mr-1.5",
|
||||||
|
todo.status === "pending"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: todo.status === "in-progress"
|
||||||
|
? "bg-sky-500"
|
||||||
|
: todo.status === "completed"
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: "bg-slate-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{todo.status.replace("-", " ")}
|
||||||
|
</Badge>
|
||||||
|
{/* Deadline Badge */}
|
||||||
|
{todo.deadline && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-normal px-2 py-0 ml-auto"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<Icons.calendar className="h-3 w-3 mr-1" />{" "}
|
||||||
|
{formatDate(todo.deadline)}{" "}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
{formData.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs mt-1">
|
||||||
|
Created {formatDate(formData.createdAt)}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{todo.image && (
|
|
||||||
<div className="w-full h-48 overflow-hidden rounded-md mb-4">
|
{/* Cover Image (find first image attachment) */}
|
||||||
<img
|
{formData.attachments?.find((att) =>
|
||||||
src={todo.image || "/placeholder.svg?height=192&width=448"}
|
att.contentType.startsWith("image/")
|
||||||
alt={todo.title}
|
) && (
|
||||||
className="w-full h-full object-cover"
|
<div className="w-full h-48 overflow-hidden relative bg-muted">
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
formData.attachments.find((att) =>
|
||||||
|
att.contentType.startsWith("image/")
|
||||||
|
)?.fileUrl || "/placeholder.svg"
|
||||||
|
}
|
||||||
|
alt={formData.title}
|
||||||
|
fill
|
||||||
|
style={{ objectFit: "cover" }}
|
||||||
|
sizes="450px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
<div className="px-6 py-4 space-y-4 max-h-[50vh] overflow-y-auto">
|
||||||
<h4 className="text-sm font-medium mb-1">Status</h4>
|
{/* Description */}
|
||||||
<Badge
|
{formData.description && (
|
||||||
className={cn(
|
<div className="text-sm text-muted-foreground">
|
||||||
"text-white",
|
{formData.description}
|
||||||
todo.status === "pending"
|
|
||||||
? "bg-yellow-500"
|
|
||||||
: todo.status === "in-progress"
|
|
||||||
? "bg-blue-500"
|
|
||||||
: "bg-green-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{todo.status.replace("-", " ")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{todo.deadline && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-1">Deadline</h4>
|
|
||||||
<p className="text-sm flex items-center gap-1">
|
|
||||||
<Icons.calendar className="h-4 w-4" />
|
|
||||||
{formatDate(todo.deadline)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{todo.description && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-1">Description</h4>
|
|
||||||
<p className="text-sm">{todo.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Tags */}
|
||||||
{todoTags.length > 0 && (
|
{todoTags.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-1">Tags</h4>
|
{" "}
|
||||||
<div className="flex flex-wrap gap-2">
|
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
|
TAGS
|
||||||
|
</h4>{" "}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{todoTags.map((tag) => (
|
{todoTags.map((tag) => (
|
||||||
<Badge key={tag.id} style={{ backgroundColor: tag.color || "#FF5A5F" }} className="text-white">
|
<Badge
|
||||||
|
key={tag.id}
|
||||||
|
variant="outline"
|
||||||
|
className="px-2 py-0.5 text-xs font-normal border-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${tag.color}20` || "#FF5A5F20",
|
||||||
|
color: tag.color || "#FF5A5F",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>{" "}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasAttachments && (
|
{/* Subtasks */}
|
||||||
|
{formData.subtasks && formData.subtasks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-1">Attachments</h4>
|
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
<ul className="space-y-2">
|
SUBTASKS (
|
||||||
{todo.attachments.map((attachment, index) => (
|
{formData.subtasks.filter((s) => s.completed).length}/
|
||||||
<li key={index} className="flex items-center gap-2 text-sm">
|
{formData.subtasks.length})
|
||||||
<Icons.paperclip className="h-4 w-4" />
|
</h4>
|
||||||
<span>{attachment}</span>
|
{/* ... Subtask list rendering ... */}
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{todo.subtasks && todo.subtasks.length > 0 && (
|
{/* Attachments */}
|
||||||
|
{(formData.attachments?.length ?? 0) > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-1">Subtasks</h4>
|
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
<ul className="space-y-2">
|
ATTACHMENTS
|
||||||
{todo.subtasks.map((subtask) => (
|
</h4>
|
||||||
<li key={subtask.id} className="flex items-center gap-2 text-sm">
|
<div className="space-y-2">
|
||||||
<Icons.check className={`h-4 w-4 ${subtask.completed ? "text-green-500" : "text-gray-300"}`} />
|
{formData.attachments.map((att) => (
|
||||||
<span className={subtask.completed ? "line-through text-muted-foreground" : ""}>
|
<div
|
||||||
{subtask.description}
|
key={att.fileId}
|
||||||
|
className="flex items-center justify-between p-2 rounded-md border bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{/* Icon or Thumbnail */}
|
||||||
|
{att.contentType.startsWith("image/") ? (
|
||||||
|
<div className="w-8 h-8 rounded overflow-hidden flex-shrink-0 bg-background">
|
||||||
|
<Image
|
||||||
|
src={att.fileUrl}
|
||||||
|
alt={att.fileName}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded bg-background flex items-center justify-center flex-shrink-0">
|
||||||
|
<Icons.file className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* File Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<a
|
||||||
|
href={att.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-medium truncate block hover:underline"
|
||||||
|
title={att.fileName}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
{att.fileName}{" "}
|
||||||
|
</a>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
{(att.size / 1024).toFixed(1)} KB -{" "}
|
||||||
|
{att.contentType}{" "}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Delete Button */}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<Icons.trash className="h-3.5 w-3.5" />{" "}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
{" "}
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Delete Attachment?
|
||||||
|
</AlertDialogTitle>{" "}
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{" "}
|
||||||
|
Are you sure you want to delete "
|
||||||
|
{att.fileName}"? This action cannot be
|
||||||
|
undone.{" "}
|
||||||
|
</AlertDialogDescription>{" "}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
{" "}
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>{" "}
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDeleteAttachment(att.fileId)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
Delete{" "}
|
||||||
|
</AlertDialogAction>{" "}
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="border-t px-6 py-3 bg-muted/30 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsViewDialogOpen(false)}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
Close{" "}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsViewDialogOpen(false);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<Icons.edit className="h-3.5 w-3.5 mr-1.5" /> Edit Todo{" "}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export function useTags() {
|
|||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["tags"],
|
queryKey: ["tags"],
|
||||||
queryFn: () => listUserTags(token),
|
queryFn: () => listUserTags(token || undefined),
|
||||||
enabled: !!token,
|
enabled: !!token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "storage.googleapis.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
44
frontend/services/api-attachments.ts
Normal file
44
frontend/services/api-attachments.ts
Normal file
@ -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<FileUploadResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
return await apiClient.upload<FileUploadResponse>(
|
||||||
|
`/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<void> {
|
||||||
|
// Ensure the path is URL encoded for the request path
|
||||||
|
const encodedPath = encodeURIComponent(attachmentPath);
|
||||||
|
await apiClient.delete<void>(
|
||||||
|
`/todos/${todoId}/attachments/${encodedPath}`,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 =
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1"
|
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1";
|
||||||
|
|
||||||
// Request options with authentication token if available
|
const getRequestOptions = (token?: string | null, isFormData = false) => {
|
||||||
const getRequestOptions = (token?: string | null) => {
|
const headers: HeadersInit = {};
|
||||||
const headers: HeadersInit = {
|
|
||||||
"Content-Type": "application/json",
|
if (!isFormData) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
headers["Authorization"] = `Bearer ${token}`
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { headers }
|
return { headers };
|
||||||
}
|
};
|
||||||
|
|
||||||
// Generic fetch function with error handling
|
async function apiFetch<T>(
|
||||||
export async function apiFetch<T>(
|
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
token?: string | null,
|
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<T> {
|
): Promise<T> {
|
||||||
if (simulateDelay > 0) {
|
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 = {
|
const requestOptions = {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
...getRequestOptions(token).headers,
|
...baseOptions.headers, // Use headers from getRequestOptions
|
||||||
...options.headers,
|
...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) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
const errorData = await response.json().catch(() => ({}));
|
||||||
throw new Error(errorData.message || `API error: ${response.status}`)
|
throw new Error(
|
||||||
|
errorData.message || `API error: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle empty responses (like for DELETE operations)
|
// Handle empty responses (like 204 No Content)
|
||||||
const contentType = response.headers.get("content-type")
|
if (response.status === 204) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
if (contentType?.includes("application/json")) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a function specifically for FormData uploads
|
||||||
|
async function apiUpload<T>(
|
||||||
|
endpoint: string,
|
||||||
|
formData: FormData,
|
||||||
|
token?: string | null
|
||||||
|
): Promise<T> {
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience methods for common HTTP methods
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
get: <T>(endpoint: string, token?: string | null) =>
|
get: <T>(endpoint: string, token?: string | null) =>
|
||||||
apiFetch<T>(endpoint, { method: 'GET' }, token),
|
apiFetch<T>(endpoint, { method: "GET" }, token),
|
||||||
|
|
||||||
post: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
post: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||||
apiFetch<T>(endpoint, {
|
apiFetch<T>(
|
||||||
method: 'POST',
|
endpoint,
|
||||||
body: JSON.stringify(data)
|
{
|
||||||
}, token),
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
),
|
||||||
|
|
||||||
put: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
put: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||||
apiFetch<T>(endpoint, {
|
apiFetch<T>(
|
||||||
method: 'PUT',
|
endpoint,
|
||||||
body: JSON.stringify(data)
|
{
|
||||||
}, token),
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
),
|
||||||
|
|
||||||
patch: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
patch: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||||
apiFetch<T>(endpoint, {
|
apiFetch<T>(
|
||||||
method: 'PATCH',
|
endpoint,
|
||||||
body: JSON.stringify(data)
|
{
|
||||||
}, token),
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
},
|
||||||
|
token
|
||||||
|
),
|
||||||
|
|
||||||
delete: <T>(endpoint: string, token?: string | null) =>
|
delete: <T>(endpoint: string, token?: string | null) =>
|
||||||
apiFetch<T>(endpoint, { method: 'DELETE' }, token),
|
apiFetch<T>(endpoint, { method: "DELETE" }, token),
|
||||||
}
|
|
||||||
|
// Expose the upload function
|
||||||
|
upload: <T>(endpoint: string, formData: FormData, token?: string | null) =>
|
||||||
|
apiUpload<T>(endpoint, formData, token),
|
||||||
|
};
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// Generated types based on the OpenAPI spec
|
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@ -59,8 +57,7 @@ export interface Todo {
|
|||||||
status: "pending" | "in-progress" | "completed"
|
status: "pending" | "in-progress" | "completed"
|
||||||
deadline?: string | null
|
deadline?: string | null
|
||||||
tagIds: string[]
|
tagIds: string[]
|
||||||
attachments: string[]
|
attachmentUrl?: string | null
|
||||||
image?: string | null
|
|
||||||
subtasks: Subtask[]
|
subtasks: Subtask[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@ -72,7 +69,6 @@ export interface CreateTodoRequest {
|
|||||||
status?: "pending" | "in-progress" | "completed"
|
status?: "pending" | "in-progress" | "completed"
|
||||||
deadline?: string | null
|
deadline?: string | null
|
||||||
tagIds?: string[]
|
tagIds?: string[]
|
||||||
image?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTodoRequest {
|
export interface UpdateTodoRequest {
|
||||||
@ -81,8 +77,7 @@ export interface UpdateTodoRequest {
|
|||||||
status?: "pending" | "in-progress" | "completed"
|
status?: "pending" | "in-progress" | "completed"
|
||||||
deadline?: string | null
|
deadline?: string | null
|
||||||
tagIds?: string[]
|
tagIds?: string[]
|
||||||
attachments?: string[]
|
// attachments are managed via separate endpoints, removed from here
|
||||||
image?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Subtask {
|
export interface Subtask {
|
||||||
@ -103,12 +98,12 @@ export interface UpdateSubtaskRequest {
|
|||||||
completed?: boolean
|
completed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileUploadResponse {
|
export type FileUploadResponse = {
|
||||||
fileId: string
|
fileId: string // Identifier used for deletion (e.g., GCS object path)
|
||||||
fileName: string
|
fileName: string // Original filename
|
||||||
fileUrl: string
|
fileUrl: string // Publicly accessible URL (e.g., signed URL)
|
||||||
contentType: string
|
contentType: string // MIME type
|
||||||
size: number
|
size: number // Size in bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Error {
|
export interface Error {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user