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:
|
||||
@echo ">> Applying migrations..."
|
||||
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
|
||||
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) up
|
||||
|
||||
migrate-down:
|
||||
@echo ">> Rolling back last migration..."
|
||||
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
|
||||
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) down 1
|
||||
|
||||
migrate-force:
|
||||
@echo ">> Forcing migration version $(VERSION)..."
|
||||
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
|
||||
@if [ -z "$(VERSION)" ]; then echo "Error: VERSION is not set. Usage: make migrate-force VERSION=<version_number>"; exit 1; fi
|
||||
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION)
|
||||
|
||||
clean:
|
||||
|
||||
@ -56,14 +56,7 @@ func main() {
|
||||
repoRegistry := repository.NewRepositoryRegistry(pool)
|
||||
|
||||
var storageService service.FileStorageService
|
||||
switch cfg.Storage.Type {
|
||||
case "local":
|
||||
storageService, err = service.NewLocalStorageService(cfg.Storage.Local, logger)
|
||||
case "gcs":
|
||||
storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported storage type: %s", cfg.Storage.Type)
|
||||
}
|
||||
storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type)
|
||||
os.Exit(1)
|
||||
|
||||
@ -7,6 +7,9 @@ server:
|
||||
idleTimeout: 60s
|
||||
basePath: "/api/v1" # Matches OpenAPI server URL
|
||||
|
||||
frontend:
|
||||
url: "http://localhost:3000"
|
||||
|
||||
database:
|
||||
url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod
|
||||
|
||||
@ -39,7 +42,5 @@ cache:
|
||||
cleanupInterval: 10m
|
||||
|
||||
storage:
|
||||
local:
|
||||
path: "/" # gcs:
|
||||
# bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME
|
||||
# credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS
|
||||
bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME
|
||||
credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS
|
||||
|
||||
@ -26,6 +26,7 @@ tool (
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/storage v1.51.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
@ -37,6 +38,7 @@ require (
|
||||
github.com/spf13/viper v1.20.1
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/oauth2 v0.29.0
|
||||
google.golang.org/api v0.229.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -47,7 +49,6 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||
cloud.google.com/go/iam v1.4.1 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.0 // indirect
|
||||
cloud.google.com/go/storage v1.51.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
|
||||
@ -114,7 +115,6 @@ require (
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
google.golang.org/api v0.229.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4=
|
||||
cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=
|
||||
cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=
|
||||
cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU=
|
||||
@ -11,16 +10,24 @@ cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4
|
||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||
cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM=
|
||||
cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||
cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q=
|
||||
cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY=
|
||||
cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM=
|
||||
cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc=
|
||||
cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw=
|
||||
cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc=
|
||||
cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE=
|
||||
cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
@ -58,8 +65,11 @@ github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@ -113,12 +123,16 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
@ -209,8 +223,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
@ -255,24 +269,18 @@ go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//sn
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
@ -295,8 +303,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||
@ -343,7 +349,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8=
|
||||
google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0=
|
||||
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
|
||||
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
|
||||
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
|
||||
|
||||
@ -139,7 +139,7 @@ func mapDomainTagToApi(tag *domain.Tag) *models.Tag {
|
||||
UpdatedAt: &updatedAt}
|
||||
}
|
||||
|
||||
func mapDomainTodoToApi(todo *domain.Todo) *models.Todo {
|
||||
func mapDomainTodoToApi(todo *domain.Todo, attachmentInfos []models.AttachmentInfo) *models.Todo { // Takes AttachmentInfo now
|
||||
if todo == nil {
|
||||
return nil
|
||||
}
|
||||
@ -162,17 +162,18 @@ func mapDomainTodoToApi(todo *domain.Todo) *models.Todo {
|
||||
updatedAt := todo.UpdatedAt
|
||||
|
||||
return &models.Todo{
|
||||
Id: &todoID,
|
||||
UserId: &userID,
|
||||
Title: todo.Title,
|
||||
Description: todo.Description,
|
||||
Status: models.TodoStatus(todo.Status),
|
||||
Deadline: todo.Deadline,
|
||||
TagIds: tagIDs,
|
||||
Attachments: todo.Attachments,
|
||||
Subtasks: &apiSubtasks,
|
||||
CreatedAt: &createdAt,
|
||||
UpdatedAt: &updatedAt}
|
||||
Id: &todoID,
|
||||
UserId: &userID,
|
||||
Title: todo.Title,
|
||||
Description: todo.Description,
|
||||
Status: models.TodoStatus(todo.Status),
|
||||
Deadline: todo.Deadline,
|
||||
TagIds: tagIDs,
|
||||
AttachmentUrl: todo.AttachmentUrl,
|
||||
Subtasks: &apiSubtasks,
|
||||
CreatedAt: &createdAt,
|
||||
UpdatedAt: &updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
|
||||
@ -193,11 +194,11 @@ func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
|
||||
UpdatedAt: &updatedAt}
|
||||
}
|
||||
|
||||
func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.FileUploadResponse {
|
||||
func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.AttachmentInfo {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return &models.FileUploadResponse{
|
||||
return &models.AttachmentInfo{
|
||||
FileId: info.FileID,
|
||||
FileName: info.FileName,
|
||||
FileUrl: info.FileURL,
|
||||
@ -577,6 +578,8 @@ func (h *ApiHandler) DeleteTagById(w http.ResponseWriter, r *http.Request, tagId
|
||||
|
||||
// --- Todo Handlers ---
|
||||
|
||||
// CreateTodo remains the same
|
||||
|
||||
func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
@ -615,10 +618,12 @@ func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
apiTodo := mapDomainTodoToApi(todo)
|
||||
// Newly created todo won't have attachments yet
|
||||
apiTodo := mapDomainTodoToApi(todo, []models.AttachmentInfo{})
|
||||
SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger)
|
||||
}
|
||||
|
||||
// ListTodos remains the same, doesn't include full attachment details for performance
|
||||
func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
@ -627,7 +632,7 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
|
||||
}
|
||||
|
||||
input := service.ListTodosInput{
|
||||
Limit: 20,
|
||||
Limit: 20, // Default limit
|
||||
Offset: 0,
|
||||
}
|
||||
if params.Limit != nil {
|
||||
@ -641,13 +646,8 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
|
||||
input.Status = &domainStatus
|
||||
}
|
||||
if params.TagId != nil {
|
||||
input.TagID = params.TagId
|
||||
}
|
||||
if params.DeadlineBefore != nil {
|
||||
input.DeadlineBefore = params.DeadlineBefore
|
||||
}
|
||||
if params.DeadlineAfter != nil {
|
||||
input.DeadlineAfter = params.DeadlineAfter
|
||||
domainTagID := uuid.UUID(*params.TagId)
|
||||
input.TagID = &domainTagID
|
||||
}
|
||||
|
||||
todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input)
|
||||
@ -658,28 +658,52 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
|
||||
|
||||
apiTodos := make([]models.Todo, len(todos))
|
||||
for i, todo := range todos {
|
||||
apiTodos[i] = *mapDomainTodoToApi(&todo)
|
||||
// For list view, if there is an attachmentUrl, include it as a single-item array
|
||||
var attachmentInfos []models.AttachmentInfo
|
||||
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
||||
attachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
||||
} else {
|
||||
attachmentInfos = []models.AttachmentInfo{}
|
||||
}
|
||||
mappedTodo := mapDomainTodoToApi(&todo, attachmentInfos)
|
||||
if mappedTodo != nil {
|
||||
apiTodos[i] = *mappedTodo
|
||||
}
|
||||
}
|
||||
|
||||
SendJSONResponse(w, http.StatusOK, apiTodos, h.logger)
|
||||
}
|
||||
|
||||
// GetTodoById updated for single attachmentUrl
|
||||
func (h *ApiHandler) GetTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
ctx := r.Context()
|
||||
userID, err := GetUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
domainTodoID := uuid.UUID(todoId)
|
||||
|
||||
todo, err := h.services.Todo.GetTodoByID(ctx, domainTodoID, userID)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
todo, err := h.services.Todo.GetTodoByID(r.Context(), todoId, userID)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
// Map attachmentUrl to API model as a single-item array if present
|
||||
var apiAttachmentInfos []models.AttachmentInfo
|
||||
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
||||
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
||||
} else {
|
||||
apiAttachmentInfos = []models.AttachmentInfo{}
|
||||
}
|
||||
|
||||
SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger)
|
||||
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
|
||||
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
|
||||
}
|
||||
|
||||
// UpdateTodoById remains the same=
|
||||
|
||||
func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
@ -711,9 +735,7 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo
|
||||
}
|
||||
input.TagIDs = &domainTagIDs
|
||||
}
|
||||
if body.Attachments != nil {
|
||||
input.Attachments = body.Attachments
|
||||
}
|
||||
// Note: Attachments are NOT updated via this endpoint in this design
|
||||
|
||||
todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input)
|
||||
if err != nil {
|
||||
@ -721,17 +743,28 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo
|
||||
return
|
||||
}
|
||||
|
||||
SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger)
|
||||
// Prepare attachment info for API response using AttachmentUrl field
|
||||
var apiAttachmentInfos []models.AttachmentInfo
|
||||
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
||||
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
||||
} else {
|
||||
apiAttachmentInfos = []models.AttachmentInfo{}
|
||||
}
|
||||
|
||||
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
|
||||
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
|
||||
}
|
||||
|
||||
// DeleteTodoById remains the same (service layer handles attachment deletion)
|
||||
func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
domainTodoID := uuid.UUID(todoId)
|
||||
|
||||
err = h.services.Todo.DeleteTodo(r.Context(), todoId, userID)
|
||||
err = h.services.Todo.DeleteTodo(r.Context(), domainTodoID, userID)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
@ -740,6 +773,72 @@ func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todo
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Attachment Handlers ---
|
||||
|
||||
func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||
ctx := r.Context()
|
||||
userID, err := GetUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
domainTodoID := uuid.UUID(todoId)
|
||||
|
||||
h.logger.DebugContext(ctx, "Request to delete attachment", "todoId", todoId)
|
||||
|
||||
err = h.services.Todo.DeleteAttachment(ctx, domainTodoID, userID)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoId)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *ApiHandler) UploadOrReplaceTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
domainTodoID := uuid.UUID(todoId)
|
||||
|
||||
// Parse multipart form (limit to 10 MB)
|
||||
err = r.ParseMultipartForm(10 << 20)
|
||||
if err != nil {
|
||||
SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", err), http.StatusBadRequest, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
file, fileHeader, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
SendJSONError(w, fmt.Errorf("missing or invalid file: %w", err), http.StatusBadRequest, h.logger)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileName := fileHeader.Filename
|
||||
fileSize := fileHeader.Size
|
||||
|
||||
todo, err := h.services.Todo.AddAttachment(r.Context(), domainTodoID, userID, fileName, fileSize, file)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare attachment info for API response using AttachmentUrl field
|
||||
var apiAttachmentInfos []models.AttachmentInfo
|
||||
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
|
||||
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
|
||||
} else {
|
||||
apiAttachmentInfos = []models.AttachmentInfo{}
|
||||
}
|
||||
|
||||
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
|
||||
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
|
||||
}
|
||||
|
||||
// --- Subtask Handlers ---
|
||||
|
||||
func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||
@ -828,53 +927,3 @@ func (h *ApiHandler) DeleteSubtaskById(w http.ResponseWriter, r *http.Request, t
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Attachment Handlers ---
|
||||
|
||||
func (h *ApiHandler) UploadTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseMultipartForm(10 << 20)
|
||||
if err != nil {
|
||||
SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
file, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
SendJSONError(w, fmt.Errorf("error retrieving the file from form-data: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileName := handler.Filename
|
||||
fileSize := handler.Size
|
||||
|
||||
attachmentInfo, err := h.services.Todo.AddAttachment(r.Context(), todoId, userID, fileName, fileSize, file)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
SendJSONResponse(w, http.StatusCreated, mapDomainAttachmentInfoToApi(attachmentInfo), h.logger)
|
||||
}
|
||||
|
||||
func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, attachmentId string) {
|
||||
userID, err := GetUserIDFromContext(r.Context())
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.services.Todo.DeleteAttachment(r.Context(), todoId, userID, attachmentId)
|
||||
if err != nil {
|
||||
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@ -42,6 +42,24 @@ const (
|
||||
ListTodosParamsStatusPending ListTodosParamsStatus = "pending"
|
||||
)
|
||||
|
||||
// AttachmentInfo Metadata about an uploaded attachment.
|
||||
type AttachmentInfo struct {
|
||||
// ContentType MIME type of the uploaded file.
|
||||
ContentType string `json:"contentType"`
|
||||
|
||||
// FileId Unique storage identifier/path for the file (used for deletion).
|
||||
FileId string `json:"fileId"`
|
||||
|
||||
// FileName Original name of the uploaded file.
|
||||
FileName string `json:"fileName"`
|
||||
|
||||
// FileUrl URL to access the uploaded file (e.g., a signed GCS URL).
|
||||
FileUrl string `json:"fileUrl"`
|
||||
|
||||
// Size Size of the uploaded file in bytes.
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// CreateSubtaskRequest Data required to create a new Subtask.
|
||||
type CreateSubtaskRequest struct {
|
||||
Description string `json:"description"`
|
||||
@ -82,23 +100,8 @@ type Error struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// FileUploadResponse Response after successfully uploading a file.
|
||||
type FileUploadResponse struct {
|
||||
// ContentType MIME type of the uploaded file.
|
||||
ContentType string `json:"contentType"`
|
||||
|
||||
// FileId Unique identifier for the uploaded file.
|
||||
FileId string `json:"fileId"`
|
||||
|
||||
// FileName Original name of the uploaded file.
|
||||
FileName string `json:"fileName"`
|
||||
|
||||
// FileUrl URL to access the uploaded file.
|
||||
FileUrl string `json:"fileUrl"`
|
||||
|
||||
// Size Size of the uploaded file in bytes.
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
// FileUploadResponse Metadata about an uploaded attachment.
|
||||
type FileUploadResponse = AttachmentInfo
|
||||
|
||||
// LoginRequest Data required for logging in via email/password.
|
||||
type LoginRequest struct {
|
||||
@ -157,35 +160,21 @@ type Tag struct {
|
||||
|
||||
// Todo Represents a Todo item.
|
||||
type Todo struct {
|
||||
// Attachments List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints.
|
||||
Attachments []string `json:"attachments"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
|
||||
// Deadline Optional deadline for the Todo item.
|
||||
Deadline *time.Time `json:"deadline"`
|
||||
|
||||
// Description Optional detailed description of the Todo.
|
||||
Description *string `json:"description"`
|
||||
Id *openapi_types.UUID `json:"id,omitempty"`
|
||||
|
||||
// Status Current status of the Todo item.
|
||||
Status TodoStatus `json:"status"`
|
||||
|
||||
// Subtasks List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints.
|
||||
Subtasks *[]Subtask `json:"subtasks,omitempty"`
|
||||
|
||||
// TagIds List of IDs of Tags associated with this Todo.
|
||||
TagIds []openapi_types.UUID `json:"tagIds"`
|
||||
|
||||
// Title The main title or task of the Todo.
|
||||
Title string `json:"title"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
|
||||
// UserId The ID of the user who owns this Todo.
|
||||
UserId *openapi_types.UUID `json:"userId,omitempty"`
|
||||
// AttachmentUrl Publicly accessible URL of the attached image, if any.
|
||||
AttachmentUrl *string `json:"attachmentUrl"`
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||
Deadline *time.Time `json:"deadline"`
|
||||
Description *string `json:"description"`
|
||||
Id *openapi_types.UUID `json:"id,omitempty"`
|
||||
Status TodoStatus `json:"status"`
|
||||
Subtasks *[]Subtask `json:"subtasks,omitempty"`
|
||||
TagIds []openapi_types.UUID `json:"tagIds"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
UserId *openapi_types.UUID `json:"userId,omitempty"`
|
||||
}
|
||||
|
||||
// TodoStatus Current status of the Todo item.
|
||||
// TodoStatus defines model for Todo.Status.
|
||||
type TodoStatus string
|
||||
|
||||
// UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional.
|
||||
@ -206,17 +195,13 @@ type UpdateTagRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTodoRequest Data for updating an existing Todo item. All fields are optional for partial updates.
|
||||
// UpdateTodoRequest Data for updating an existing Todo item. Attachment is managed via dedicated endpoints.
|
||||
type UpdateTodoRequest struct {
|
||||
// Attachments Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files.
|
||||
Attachments *[]string `json:"attachments,omitempty"`
|
||||
Deadline *time.Time `json:"deadline"`
|
||||
Description *string `json:"description"`
|
||||
Status *UpdateTodoRequestStatus `json:"status,omitempty"`
|
||||
|
||||
// TagIds Replace the existing list of associated Tag IDs. IDs must belong to the user.
|
||||
TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTodoRequestStatus defines model for UpdateTodoRequest.Status.
|
||||
@ -259,30 +244,17 @@ type Unauthorized = Error
|
||||
|
||||
// ListTodosParams defines parameters for ListTodos.
|
||||
type ListTodosParams struct {
|
||||
// Status Filter Todos by status.
|
||||
Status *ListTodosParamsStatus `form:"status,omitempty" json:"status,omitempty"`
|
||||
|
||||
// TagId Filter Todos by a specific Tag ID.
|
||||
TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"`
|
||||
|
||||
// DeadlineBefore Filter Todos with deadline before this date/time.
|
||||
DeadlineBefore *time.Time `form:"deadline_before,omitempty" json:"deadline_before,omitempty"`
|
||||
|
||||
// DeadlineAfter Filter Todos with deadline after this date/time.
|
||||
DeadlineAfter *time.Time `form:"deadline_after,omitempty" json:"deadline_after,omitempty"`
|
||||
|
||||
// Limit Maximum number of Todos to return.
|
||||
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
|
||||
// Offset Number of Todos to skip for pagination.
|
||||
Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
|
||||
TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"`
|
||||
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
Offset *int `form:"offset,omitempty" json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
// ListTodosParamsStatus defines parameters for ListTodos.
|
||||
type ListTodosParamsStatus string
|
||||
|
||||
// UploadTodoAttachmentMultipartBody defines parameters for UploadTodoAttachment.
|
||||
type UploadTodoAttachmentMultipartBody struct {
|
||||
// UploadOrReplaceTodoAttachmentMultipartBody defines parameters for UploadOrReplaceTodoAttachment.
|
||||
type UploadOrReplaceTodoAttachmentMultipartBody struct {
|
||||
File openapi_types.File `json:"file"`
|
||||
}
|
||||
|
||||
@ -304,8 +276,8 @@ type CreateTodoJSONRequestBody = CreateTodoRequest
|
||||
// UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType.
|
||||
type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest
|
||||
|
||||
// UploadTodoAttachmentMultipartRequestBody defines body for UploadTodoAttachment for multipart/form-data ContentType.
|
||||
type UploadTodoAttachmentMultipartRequestBody UploadTodoAttachmentMultipartBody
|
||||
// UploadOrReplaceTodoAttachmentMultipartRequestBody defines body for UploadOrReplaceTodoAttachment for multipart/form-data ContentType.
|
||||
type UploadOrReplaceTodoAttachmentMultipartRequestBody UploadOrReplaceTodoAttachmentMultipartBody
|
||||
|
||||
// CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType.
|
||||
type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
@ -16,20 +16,30 @@ const (
|
||||
)
|
||||
|
||||
type Todo struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"userId"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"` // Nullable
|
||||
Status TodoStatus `json:"status"`
|
||||
Deadline *time.Time `json:"deadline"` // Nullable
|
||||
TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching
|
||||
Tags []Tag `json:"-"` // Can hold full tag objects if needed, loaded separately
|
||||
Attachments []string `json:"attachments"` // Stores identifiers (e.g., file IDs or URLs)
|
||||
Subtasks []Subtask `json:"subtasks"` // Populated after fetching
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"userId"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"` // Nullable
|
||||
Status TodoStatus `json:"status"`
|
||||
Deadline *time.Time `json:"deadline"` // Nullable
|
||||
TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching
|
||||
Tags []Tag `json:"-"` // Loaded separately
|
||||
AttachmentUrl *string `json:"attachmentUrl"` // Renamed and changed type
|
||||
Subtasks []Subtask `json:"subtasks"` // Populated after fetching
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Keep AttachmentInfo for upload responses
|
||||
type AttachmentInfo struct {
|
||||
FileID string `json:"fileId"`
|
||||
FileName string `json:"fileName"`
|
||||
FileURL string `json:"fileUrl"`
|
||||
ContentType string `json:"contentType"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Helper functions remain the same
|
||||
func NullStringToStringPtr(ns sql.NullString) *string {
|
||||
if ns.Valid {
|
||||
return &ns.String
|
||||
|
||||
@ -40,7 +40,7 @@ type ListTodosParams struct {
|
||||
TagID *uuid.UUID
|
||||
DeadlineBefore *time.Time
|
||||
DeadlineAfter *time.Time
|
||||
ListParams // Embed pagination
|
||||
ListParams
|
||||
}
|
||||
|
||||
type TodoRepository interface {
|
||||
@ -54,10 +54,8 @@ type TodoRepository interface {
|
||||
RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error
|
||||
SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error
|
||||
GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error)
|
||||
// Attachment associations (using simple string array)
|
||||
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
||||
RemoveAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
||||
SetAttachments(ctx context.Context, todoID, userID uuid.UUID, attachmentIDs []string) error
|
||||
// Attachment URL management
|
||||
UpdateAttachmentURL(ctx context.Context, todoID, userID uuid.UUID, attachmentURL *string) error
|
||||
}
|
||||
|
||||
type SubtaskRepository interface {
|
||||
|
||||
@ -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
|
||||
INSERT INTO todos (user_id, title, description, status, deadline)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO todos (user_id, title, description, status, deadline, attachment_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetTodoByID :one
|
||||
@ -11,13 +11,13 @@ WHERE id = $1 AND user_id = $2 LIMIT 1;
|
||||
SELECT t.* FROM todos t
|
||||
LEFT JOIN todo_tags tt ON t.id = tt.todo_id
|
||||
WHERE
|
||||
t.user_id = sqlc.arg('user_id') -- Use sqlc.arg for required params
|
||||
t.user_id = sqlc.arg('user_id')
|
||||
AND (sqlc.narg('status_filter')::todo_status IS NULL OR t.status = sqlc.narg('status_filter'))
|
||||
AND (sqlc.narg('tag_id_filter')::uuid IS NULL OR tt.tag_id = sqlc.narg('tag_id_filter'))
|
||||
AND (sqlc.narg('deadline_before_filter')::timestamptz IS NULL OR t.deadline < sqlc.narg('deadline_before_filter'))
|
||||
AND (sqlc.narg('deadline_after_filter')::timestamptz IS NULL OR t.deadline > sqlc.narg('deadline_after_filter'))
|
||||
GROUP BY t.id -- Still needed due to LEFT JOIN potentially multiplying rows if a todo has multiple tags
|
||||
ORDER BY t.created_at DESC -- Or your desired order
|
||||
GROUP BY t.id
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT sqlc.arg('limit')
|
||||
OFFSET sqlc.arg('offset');
|
||||
|
||||
@ -25,10 +25,10 @@ OFFSET sqlc.arg('offset');
|
||||
UPDATE todos
|
||||
SET
|
||||
title = COALESCE(sqlc.narg(title), title),
|
||||
description = sqlc.narg(description), -- Allow setting description to NULL
|
||||
description = sqlc.narg(description),
|
||||
status = COALESCE(sqlc.narg(status), status),
|
||||
deadline = sqlc.narg(deadline), -- Allow setting deadline to NULL
|
||||
attachments = COALESCE(sqlc.narg(attachments), attachments)
|
||||
deadline = sqlc.narg(deadline),
|
||||
attachment_url = COALESCE(sqlc.narg(attachment_url), attachment_url) -- Update attachment_url
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING *;
|
||||
|
||||
@ -36,12 +36,8 @@ RETURNING *;
|
||||
DELETE FROM todos
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: AddAttachmentToTodo :exec
|
||||
-- name: UpdateTodoAttachmentURL :exec
|
||||
-- Sets or clears the attachment URL for a specific todo
|
||||
UPDATE todos
|
||||
SET attachments = array_append(attachments, $1)
|
||||
WHERE id = $2 AND user_id = $3;
|
||||
|
||||
-- name: RemoveAttachmentFromTodo :exec
|
||||
UPDATE todos
|
||||
SET attachments = array_remove(attachments, $1)
|
||||
SET attachment_url = $1 -- $1 will be the URL (TEXT) or NULL
|
||||
WHERE id = $2 AND user_id = $3;
|
||||
@ -29,15 +29,15 @@ func NewPgxTodoRepository(queries *db.Queries, pool *pgxpool.Pool) TodoRepositor
|
||||
|
||||
func mapDbTodoToDomain(dbTodo db.Todo) *domain.Todo {
|
||||
return &domain.Todo{
|
||||
ID: dbTodo.ID,
|
||||
UserID: dbTodo.UserID,
|
||||
Title: dbTodo.Title,
|
||||
Description: domain.NullStringToStringPtr(dbTodo.Description),
|
||||
Status: domain.TodoStatus(dbTodo.Status),
|
||||
Deadline: dbTodo.Deadline,
|
||||
Attachments: dbTodo.Attachments,
|
||||
CreatedAt: dbTodo.CreatedAt,
|
||||
UpdatedAt: dbTodo.UpdatedAt,
|
||||
ID: dbTodo.ID,
|
||||
UserID: dbTodo.UserID,
|
||||
Title: dbTodo.Title,
|
||||
Description: domain.NullStringToStringPtr(dbTodo.Description),
|
||||
Status: domain.TodoStatus(dbTodo.Status),
|
||||
AttachmentUrl: domain.NullStringToStringPtr(dbTodo.AttachmentUrl),
|
||||
Deadline: dbTodo.Deadline,
|
||||
CreatedAt: dbTodo.CreatedAt,
|
||||
UpdatedAt: dbTodo.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,7 +161,6 @@ func (r *pgxTodoRepository) Update(
|
||||
Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil},
|
||||
Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true},
|
||||
Deadline: updateData.Deadline,
|
||||
Attachments: updateData.Attachments,
|
||||
}
|
||||
|
||||
dbTodo, err := r.q.UpdateTodo(ctx, params)
|
||||
@ -251,61 +250,19 @@ func (r *pgxTodoRepository) GetTags(
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// --- Attachments (String Identifiers in Array) ---
|
||||
|
||||
func (r *pgxTodoRepository) AddAttachment(
|
||||
func (r *pgxTodoRepository) UpdateAttachmentURL(
|
||||
ctx context.Context,
|
||||
todoID, userID uuid.UUID,
|
||||
attachmentID string,
|
||||
attachmentURL *string,
|
||||
) error {
|
||||
if _, err := r.GetByID(ctx, todoID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.q.AddAttachmentToTodo(ctx, db.AddAttachmentToTodoParams{
|
||||
ArrayAppend: attachmentID,
|
||||
ID: todoID,
|
||||
UserID: userID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to add attachment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgxTodoRepository) RemoveAttachment(
|
||||
ctx context.Context,
|
||||
todoID, userID uuid.UUID,
|
||||
attachmentID string,
|
||||
) error {
|
||||
if _, err := r.GetByID(ctx, todoID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.q.RemoveAttachmentFromTodo(ctx, db.RemoveAttachmentFromTodoParams{
|
||||
ArrayRemove: attachmentID,
|
||||
ID: todoID,
|
||||
UserID: userID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to remove attachment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgxTodoRepository) SetAttachments(
|
||||
ctx context.Context,
|
||||
todoID, userID uuid.UUID,
|
||||
attachmentIDs []string,
|
||||
) error {
|
||||
_, err := r.GetByID(ctx, todoID, userID)
|
||||
query := `
|
||||
UPDATE todos
|
||||
SET attachment_url = $1
|
||||
WHERE id = $2 AND user_id = $3
|
||||
`
|
||||
_, err := r.pool.Exec(ctx, query, attachmentURL, todoID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updateParams := db.UpdateTodoParams{
|
||||
ID: todoID,
|
||||
UserID: userID,
|
||||
Attachments: attachmentIDs,
|
||||
}
|
||||
_, err = r.q.UpdateTodo(ctx, updateParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set attachments using UpdateTodo: %w", err)
|
||||
return fmt.Errorf("failed to update attachment URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -2,12 +2,14 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/storage"
|
||||
"github.com/Sosokker/todolist-backend/internal/config"
|
||||
@ -16,79 +18,153 @@ import (
|
||||
)
|
||||
|
||||
type gcsStorageService struct {
|
||||
bucket string
|
||||
client *storage.Client
|
||||
logger *slog.Logger
|
||||
baseDir string
|
||||
bucket string
|
||||
client *storage.Client
|
||||
logger *slog.Logger
|
||||
baseDir string
|
||||
signedURLExpiry time.Duration
|
||||
}
|
||||
|
||||
func NewGCStorageService(cfg config.GCSStorageConfig, logger *slog.Logger) (FileStorageService, error) {
|
||||
if cfg.BucketName == "" {
|
||||
return nil, fmt.Errorf("GCS bucket name is required")
|
||||
}
|
||||
|
||||
opts := []option.ClientOption{}
|
||||
// Prefer environment variable GOOGLE_APPLICATION_CREDENTIALS
|
||||
// Only use CredentialsFile from config if it's explicitly set
|
||||
if cfg.CredentialsFile != "" {
|
||||
opts = append(opts, option.WithCredentialsFile(cfg.CredentialsFile))
|
||||
logger.Info("Using GCS credentials file specified in config", "path", cfg.CredentialsFile)
|
||||
} else {
|
||||
logger.Info("Using default GCS credentials (e.g., GOOGLE_APPLICATION_CREDENTIALS or Application Default Credentials)")
|
||||
}
|
||||
client, err := storage.NewClient(context.Background(), opts...)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := storage.NewClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCS client: %w", err)
|
||||
}
|
||||
|
||||
// Check bucket existence and permissions
|
||||
_, err = client.Bucket(cfg.BucketName).Attrs(ctx)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("failed to access GCS bucket '%s': %w", cfg.BucketName, err)
|
||||
}
|
||||
|
||||
logger.Info("GCS storage service initialized", "bucket", cfg.BucketName, "baseDir", cfg.BaseDir)
|
||||
|
||||
return &gcsStorageService{
|
||||
bucket: cfg.BucketName,
|
||||
client: client,
|
||||
logger: logger.With("service", "gcsstorage"),
|
||||
baseDir: cfg.BaseDir,
|
||||
bucket: cfg.BucketName,
|
||||
client: client,
|
||||
logger: logger.With("service", "gcsstorage"),
|
||||
baseDir: strings.Trim(cfg.BaseDir, "/"), // Ensure no leading/trailing slashes
|
||||
signedURLExpiry: 168 * time.Hour, // Default signed URL validity
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *gcsStorageService) GenerateUniqueObjectName(originalFilename string) string {
|
||||
// GenerateUniqueObjectName creates a unique object path within the bucket's base directory.
|
||||
// Example: attachments/<user_uuid>/<todo_uuid>/<file_uuid>.<ext>
|
||||
func (s *gcsStorageService) GenerateUniqueObjectName(userID, todoID uuid.UUID, originalFilename string) string {
|
||||
ext := filepath.Ext(originalFilename)
|
||||
return uuid.NewString() + ext
|
||||
fileName := uuid.NewString() + ext
|
||||
objectPath := filepath.Join(s.baseDir, userID.String(), todoID.String(), fileName)
|
||||
return filepath.ToSlash(objectPath)
|
||||
}
|
||||
|
||||
func (s *gcsStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) {
|
||||
objectName := filepath.Join(s.baseDir, userID.String(), todoID.String(), s.GenerateUniqueObjectName(originalFilename))
|
||||
wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctx)
|
||||
wc.ContentType = mime.TypeByExtension(filepath.Ext(originalFilename))
|
||||
wc.ChunkSize = 0
|
||||
objectName := s.GenerateUniqueObjectName(userID, todoID, originalFilename)
|
||||
|
||||
ctxUpload, cancel := context.WithTimeout(ctx, 5*time.Minute) // Timeout for upload
|
||||
defer cancel()
|
||||
|
||||
wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctxUpload)
|
||||
|
||||
// Attempt to determine Content-Type
|
||||
contentType := mime.TypeByExtension(filepath.Ext(originalFilename))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream" // Default fallback
|
||||
// Could potentially read first 512 bytes from reader here if it's TeeReader, but might be complex
|
||||
}
|
||||
wc.ContentType = contentType
|
||||
wc.ChunkSize = 0 // Recommended for better performance unless files are huge
|
||||
|
||||
s.logger.DebugContext(ctx, "Uploading file to GCS", "bucket", s.bucket, "object", objectName, "contentType", contentType, "size", size)
|
||||
|
||||
written, err := io.Copy(wc, reader)
|
||||
if err != nil {
|
||||
wc.Close()
|
||||
s.logger.ErrorContext(ctx, "Failed to upload to GCS", "error", err, "object", objectName)
|
||||
// Close writer explicitly on error to clean up potential partial uploads
|
||||
_ = wc.CloseWithError(fmt.Errorf("copy failed: %w", err))
|
||||
s.logger.ErrorContext(ctx, "Failed to copy data to GCS", "error", err, "object", objectName)
|
||||
return "", "", fmt.Errorf("failed to upload to GCS: %w", err)
|
||||
}
|
||||
if written != size {
|
||||
wc.Close()
|
||||
s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName)
|
||||
return "", "", fmt.Errorf("file size mismatch during upload")
|
||||
}
|
||||
|
||||
// Close the writer to finalize the upload
|
||||
if err := wc.Close(); err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName)
|
||||
return "", "", fmt.Errorf("failed to finalize upload: %w", err)
|
||||
}
|
||||
contentType := wc.ContentType
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
|
||||
if written != size {
|
||||
s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName)
|
||||
// Optionally delete the potentially corrupted file
|
||||
_ = s.Delete(context.Background(), objectName) // Use background context for cleanup
|
||||
return "", "", fmt.Errorf("file size mismatch during upload")
|
||||
}
|
||||
|
||||
s.logger.InfoContext(ctx, "File uploaded successfully to GCS", "object", objectName, "size", written, "contentType", contentType)
|
||||
// Return the object name (path) as the storage ID
|
||||
return objectName, contentType, nil
|
||||
}
|
||||
|
||||
func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error {
|
||||
objectName := filepath.Clean(storageID)
|
||||
if strings.Contains(objectName, "..") {
|
||||
s.logger.WarnContext(ctx, "Attempted directory traversal in GCS delete", "storageId", storageID)
|
||||
return fmt.Errorf("invalid storage ID")
|
||||
objectName := filepath.Clean(storageID) // storageID is the object path
|
||||
if strings.Contains(objectName, "..") || !strings.HasPrefix(objectName, s.baseDir+"/") && s.baseDir != "" {
|
||||
s.logger.WarnContext(ctx, "Attempted invalid delete operation", "storageId", storageID, "baseDir", s.baseDir)
|
||||
return fmt.Errorf("invalid storage ID for deletion")
|
||||
}
|
||||
|
||||
ctxDelete, cancel := context.WithTimeout(ctx, 30*time.Second) // Timeout for delete
|
||||
defer cancel()
|
||||
|
||||
o := s.client.Bucket(s.bucket).Object(objectName)
|
||||
err := o.Delete(ctx)
|
||||
if err != nil && err != storage.ErrObjectNotExist {
|
||||
err := o.Delete(ctxDelete)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrObjectNotExist) {
|
||||
s.logger.WarnContext(ctx, "Attempted to delete non-existent GCS object", "storageId", storageID)
|
||||
return nil // Treat as success if already deleted
|
||||
}
|
||||
s.logger.ErrorContext(ctx, "Failed to delete GCS object", "error", err, "storageId", storageID)
|
||||
return fmt.Errorf("could not delete GCS object: %w", err)
|
||||
}
|
||||
s.logger.InfoContext(ctx, "GCS object deleted", "storageId", storageID)
|
||||
|
||||
s.logger.InfoContext(ctx, "GCS object deleted successfully", "storageId", storageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetURL generates a signed URL for accessing the private GCS object.
|
||||
func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) {
|
||||
objectName := filepath.Clean(storageID)
|
||||
url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", s.bucket, objectName)
|
||||
objectName := storageID
|
||||
if strings.Contains(objectName, "..") || (s.baseDir != "" && !strings.HasPrefix(objectName, s.baseDir+"/")) {
|
||||
s.logger.WarnContext(ctx, "Attempted invalid GetURL operation", "storageId", storageID, "baseDir", s.baseDir)
|
||||
return "", fmt.Errorf("invalid storage ID for URL generation")
|
||||
}
|
||||
|
||||
opts := &storage.SignedURLOptions{
|
||||
Scheme: storage.SigningSchemeV4,
|
||||
Method: "GET",
|
||||
Expires: time.Now().Add(s.signedURLExpiry),
|
||||
}
|
||||
|
||||
url, err := s.client.Bucket(s.bucket).SignedURL(objectName, opts)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to generate signed URL", "error", err, "object", objectName)
|
||||
return "", fmt.Errorf("could not get signed URL for object: %w", err)
|
||||
}
|
||||
|
||||
s.logger.DebugContext(ctx, "Generated signed URL", "object", objectName, "expiry", opts.Expires)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
@ -78,7 +78,7 @@ type UpdateTodoInput struct {
|
||||
Status *domain.TodoStatus
|
||||
Deadline *time.Time
|
||||
TagIDs *[]uuid.UUID
|
||||
Attachments *[]string
|
||||
// Attachments are managed via separate endpoints
|
||||
}
|
||||
|
||||
type ListTodosInput struct {
|
||||
@ -92,18 +92,19 @@ type ListTodosInput struct {
|
||||
|
||||
type TodoService interface {
|
||||
CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error)
|
||||
GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Includes tags, subtasks
|
||||
ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) // Includes tags
|
||||
GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Fetches attachment URL
|
||||
ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error)
|
||||
UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error)
|
||||
DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error
|
||||
// Subtask methods delegate to SubtaskService but check Todo ownership first
|
||||
// Subtask methods
|
||||
ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error)
|
||||
CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error)
|
||||
UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error)
|
||||
DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error
|
||||
// Attachment methods
|
||||
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) // Returns info like ID/URL
|
||||
DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
|
||||
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.Todo, error)
|
||||
// Uploads, gets URL, updates Todo, returns updated Todo
|
||||
DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID) error // Deletes from storage and clears Todo URL
|
||||
}
|
||||
|
||||
// --- Subtask Service ---
|
||||
@ -131,9 +132,10 @@ type FileStorageService interface {
|
||||
Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (storageID string, contentType string, err error)
|
||||
// Delete removes the file associated with the given storage identifier.
|
||||
Delete(ctx context.Context, storageID string) error
|
||||
// GetURL retrieves a publicly accessible URL for the storage ID (optional, might not be needed if files are served differently).
|
||||
// GetURL retrieves a publicly accessible URL for the storage ID (e.g., signed URL for GCS).
|
||||
GetURL(ctx context.Context, storageID string) (string, error)
|
||||
GenerateUniqueObjectName(originalFilename string) string
|
||||
// GenerateUniqueObjectName creates a unique storage path/name for a file.
|
||||
GenerateUniqueObjectName(userID, todoID uuid.UUID, originalFilename string) string
|
||||
}
|
||||
|
||||
// ServiceRegistry bundles services
|
||||
|
||||
@ -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"
|
||||
"io"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/Sosokker/todolist-backend/internal/domain"
|
||||
"github.com/Sosokker/todolist-backend/internal/repository"
|
||||
@ -14,8 +15,8 @@ import (
|
||||
|
||||
type todoService struct {
|
||||
todoRepo repository.TodoRepository
|
||||
tagService TagService // Depend on TagService for validation
|
||||
subtaskService SubtaskService // Depend on SubtaskService
|
||||
tagService TagService
|
||||
subtaskService SubtaskService
|
||||
storageService FileStorageService
|
||||
logger *slog.Logger
|
||||
}
|
||||
@ -56,13 +57,13 @@ func (s *todoService) CreateTodo(ctx context.Context, userID uuid.UUID, input Cr
|
||||
}
|
||||
|
||||
newTodo := &domain.Todo{
|
||||
UserID: userID,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Status: status,
|
||||
Deadline: input.Deadline,
|
||||
TagIDs: input.TagIDs,
|
||||
Attachments: []string{},
|
||||
UserID: userID,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Status: status,
|
||||
Deadline: input.Deadline,
|
||||
TagIDs: input.TagIDs,
|
||||
AttachmentUrl: nil, // No attachment on creation
|
||||
}
|
||||
|
||||
createdTodo, err := s.todoRepo.Create(ctx, newTodo)
|
||||
@ -114,6 +115,9 @@ func (s *todoService) GetTodoByID(ctx context.Context, todoID, userID uuid.UUID)
|
||||
todo.Subtasks = subtasks
|
||||
}
|
||||
|
||||
// Note: todo.Attachments currently holds storage IDs (paths).
|
||||
// The handler will call GetAttachmentURLs to convert these to full URLs for the API response.
|
||||
|
||||
return todo, nil
|
||||
}
|
||||
|
||||
@ -160,20 +164,21 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID,
|
||||
}
|
||||
|
||||
updateData := &domain.Todo{
|
||||
ID: existingTodo.ID,
|
||||
UserID: existingTodo.UserID,
|
||||
Title: existingTodo.Title,
|
||||
Description: existingTodo.Description,
|
||||
Status: existingTodo.Status,
|
||||
Deadline: existingTodo.Deadline,
|
||||
Attachments: existingTodo.Attachments,
|
||||
ID: existingTodo.ID,
|
||||
UserID: existingTodo.UserID,
|
||||
Title: existingTodo.Title,
|
||||
Description: existingTodo.Description,
|
||||
Status: existingTodo.Status,
|
||||
Deadline: existingTodo.Deadline,
|
||||
TagIDs: existingTodo.TagIDs,
|
||||
AttachmentUrl: existingTodo.AttachmentUrl, // Single attachment URL
|
||||
}
|
||||
|
||||
updated := false
|
||||
|
||||
if input.Title != nil {
|
||||
if *input.Title == "" {
|
||||
return nil, fmt.Errorf("title cannot be empty: %w", domain.ErrValidation)
|
||||
if err := ValidateTodoTitle(*input.Title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updateData.Title = *input.Title
|
||||
updated = true
|
||||
@ -203,21 +208,10 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID,
|
||||
s.logger.ErrorContext(ctx, "Failed to update tags for todo", "error", err, "todoId", todoID)
|
||||
return nil, domain.ErrInternalServer
|
||||
}
|
||||
updateData.TagIDs = *input.TagIDs
|
||||
tagsUpdated = true
|
||||
}
|
||||
|
||||
attachmentsUpdated := false
|
||||
if input.Attachments != nil {
|
||||
err = s.todoRepo.SetAttachments(ctx, todoID, userID, *input.Attachments)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to update attachments list for todo", "error", err, "todoId", todoID)
|
||||
return nil, domain.ErrInternalServer
|
||||
}
|
||||
updateData.Attachments = *input.Attachments
|
||||
attachmentsUpdated = true
|
||||
}
|
||||
|
||||
// Update the core fields if anything changed
|
||||
var updatedRepoTodo *domain.Todo
|
||||
if updated {
|
||||
updatedRepoTodo, err = s.todoRepo.Update(ctx, todoID, userID, updateData)
|
||||
@ -226,42 +220,57 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID,
|
||||
return nil, domain.ErrInternalServer
|
||||
}
|
||||
} else {
|
||||
updatedRepoTodo = updateData
|
||||
// If only tags were updated, we still need the latest full todo data
|
||||
updatedRepoTodo = existingTodo
|
||||
}
|
||||
|
||||
if !updated && (tagsUpdated || attachmentsUpdated) {
|
||||
updatedRepoTodo.Title = existingTodo.Title
|
||||
updatedRepoTodo.Description = existingTodo.Description
|
||||
// If tags were updated, reload the full todo to get the updated TagIDs array
|
||||
if tagsUpdated {
|
||||
reloadedTodo, reloadErr := s.GetTodoByID(ctx, todoID, userID)
|
||||
if reloadErr != nil {
|
||||
s.logger.WarnContext(ctx, "Failed to reload todo after tag update, returning potentially stale data", "error", reloadErr, "todoId", todoID)
|
||||
// Return the todo data we have, even if tags might be slightly out of sync temporarily
|
||||
if updatedRepoTodo != nil {
|
||||
updatedRepoTodo.TagIDs = *input.TagIDs // Manually set IDs based on input
|
||||
return updatedRepoTodo, nil
|
||||
}
|
||||
return existingTodo, nil // Fallback
|
||||
}
|
||||
return reloadedTodo, nil
|
||||
}
|
||||
|
||||
finalTodo, err := s.GetTodoByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "Failed to reload todo after update, returning partial data", "error", err, "todoId", todoID)
|
||||
return updatedRepoTodo, nil
|
||||
}
|
||||
|
||||
return finalTodo, nil
|
||||
return updatedRepoTodo, nil // Return the result from repo Update or existing if only tags changed
|
||||
}
|
||||
|
||||
func (s *todoService) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error {
|
||||
existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return nil // Already deleted or doesn't exist/belong to user
|
||||
}
|
||||
return err // Internal error
|
||||
}
|
||||
|
||||
attachmentIDsToDelete := existingTodo.Attachments
|
||||
|
||||
// Delete the Todo record from the database first
|
||||
err = s.todoRepo.Delete(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID)
|
||||
return domain.ErrInternalServer
|
||||
}
|
||||
|
||||
for _, storageID := range attachmentIDsToDelete {
|
||||
if err := s.storageService.Delete(ctx, storageID); err != nil {
|
||||
// If there is an attachment, attempt to delete it from storage (best effort)
|
||||
if existingTodo.AttachmentUrl != nil {
|
||||
storageID := *existingTodo.AttachmentUrl
|
||||
deleteCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.storageService.Delete(deleteCtx, storageID); err != nil {
|
||||
s.logger.WarnContext(ctx, "Failed to delete attachment file during todo deletion", "error", err, "storageId", storageID, "todoId", todoID)
|
||||
} else {
|
||||
s.logger.InfoContext(ctx, "Deleted attachment file during todo deletion", "storageId", storageID, "todoId", todoID)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.InfoContext(ctx, "Successfully deleted todo and attempted attachment cleanup", "todoId", todoID, "userId", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -284,72 +293,67 @@ func (s *todoService) CreateSubtask(ctx context.Context, todoID, userID uuid.UUI
|
||||
}
|
||||
|
||||
func (s *todoService) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) {
|
||||
// Check if parent todo belongs to user first (optional but safer)
|
||||
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Subtask service's GetByID/Update methods inherently check ownership via JOINs
|
||||
return s.subtaskService.Update(ctx, subtaskID, userID, input)
|
||||
}
|
||||
|
||||
func (s *todoService) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error {
|
||||
// Check if parent todo belongs to user first (optional but safer)
|
||||
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Subtask service's Delete method inherently checks ownership via JOINs
|
||||
return s.subtaskService.Delete(ctx, subtaskID, userID)
|
||||
}
|
||||
|
||||
// --- Attachment Methods --- (Implementation depends on FileStorageService)
|
||||
// --- Attachment Methods (Simplified) ---
|
||||
|
||||
func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, originalFilename string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) {
|
||||
func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.Todo, error) {
|
||||
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storageID, contentType, err := s.storageService.Upload(ctx, userID, todoID, originalFilename, fileContent, fileSize)
|
||||
storageID, _, err := s.storageService.Upload(ctx, userID, todoID, fileName, fileContent, fileSize)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to upload attachment to storage", "error", err, "todoId", todoID, "fileName", originalFilename)
|
||||
return nil, domain.ErrInternalServer
|
||||
s.logger.ErrorContext(ctx, "Failed to upload attachment", "error", err, "todoId", todoID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = s.todoRepo.AddAttachment(ctx, todoID, userID, storageID); err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to add attachment storage ID to todo", "error", err, "todoId", todoID, "storageId", storageID)
|
||||
if delErr := s.storageService.Delete(context.Background(), storageID); delErr != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to delete orphaned attachment file after DB error", "deleteError", delErr, "storageId", storageID)
|
||||
}
|
||||
return nil, domain.ErrInternalServer
|
||||
// Construct the public URL for the uploaded file in GCS
|
||||
publicURL, err := s.storageService.GetURL(ctx, storageID)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to generate public URL for attachment", "error", err, "todoId", todoID, "storageId", storageID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileURL, _ := s.storageService.GetURL(ctx, storageID)
|
||||
if err := s.todoRepo.UpdateAttachmentURL(ctx, todoID, userID, &publicURL); err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to update attachment URL in repo", "error", err, "todoId", todoID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.AttachmentInfo{
|
||||
FileID: storageID,
|
||||
FileName: originalFilename,
|
||||
FileURL: fileURL,
|
||||
ContentType: contentType,
|
||||
Size: fileSize,
|
||||
}, nil
|
||||
s.logger.InfoContext(ctx, "Attachment added successfully", "todoId", todoID, "storageId", storageID)
|
||||
|
||||
return s.GetTodoByID(ctx, todoID, userID)
|
||||
}
|
||||
|
||||
func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, storageID string) error {
|
||||
todo, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||
func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID) error {
|
||||
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, att := range todo.Attachments {
|
||||
if att == storageID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("attachment '%s' not found on todo %s: %w", storageID, todoID, domain.ErrNotFound)
|
||||
}
|
||||
|
||||
if err = s.todoRepo.RemoveAttachment(ctx, todoID, userID, storageID); err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to remove attachment ID from todo", "error", err, "todoId", todoID, "storageId", storageID)
|
||||
return domain.ErrInternalServer
|
||||
}
|
||||
|
||||
if err = s.storageService.Delete(ctx, storageID); err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to delete attachment file from storage after removing DB ref", "error", err, "storageId", storageID)
|
||||
return nil
|
||||
if err := s.todoRepo.UpdateAttachmentURL(ctx, todoID, userID, nil); err != nil {
|
||||
s.logger.ErrorContext(ctx, "Failed to update attachment URL in repo", "error", err, "todoId", todoID)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoID)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
info:
|
||||
title: Todolist API
|
||||
version: 1.2.0 # Incremented version
|
||||
version: 1.3.0 # Incremented version
|
||||
description: |
|
||||
API for managing Todo items, including CRUD operations, subtasks, deadlines, attachments, and user-defined Tags.
|
||||
API for managing Todo items, including CRUD operations, subtasks, deadlines, attachments (stored in GCS), and user-defined Tags.
|
||||
Supports user authentication via email/password (JWT) and Google OAuth.
|
||||
Designed for use with oapi-codegen and Chi.
|
||||
|
||||
@ -11,26 +11,22 @@ info:
|
||||
|
||||
**Note on Tag Deletion:** Deleting a Tag will typically remove its association from any Todo items currently using it.
|
||||
servers:
|
||||
# The base path for all API routes defined below.
|
||||
# oapi-codegen will use this when setting up routes with HandlerFromMux.
|
||||
- url: /api/v1
|
||||
description: API version 1
|
||||
|
||||
components:
|
||||
# Security Schemes used by the API
|
||||
securitySchemes:
|
||||
BearerAuth: # Used by API clients (non-browser)
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: JWT authentication token provided in the Authorization header.
|
||||
CookieAuth: # Used by the web application (browser)
|
||||
CookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: jwt_token # Name needs to match config.AppConfig.CookieName
|
||||
name: jwt_token
|
||||
description: JWT authentication token provided via an HTTP-only cookie.
|
||||
|
||||
# Reusable Schemas
|
||||
schemas:
|
||||
# --- User Schemas ---
|
||||
User:
|
||||
@ -80,7 +76,7 @@ components:
|
||||
password:
|
||||
type: string
|
||||
minLength: 6
|
||||
writeOnly: true # Password should not appear in responses
|
||||
writeOnly: true
|
||||
required:
|
||||
- username
|
||||
- email
|
||||
@ -123,9 +119,6 @@ components:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 50
|
||||
# Add other updatable fields like email if needed (consider verification flow)
|
||||
# Password updates might warrant a separate endpoint /users/me/password
|
||||
# No required fields, allows partial updates
|
||||
|
||||
# --- Tag Schemas ---
|
||||
Tag:
|
||||
@ -146,7 +139,7 @@ components:
|
||||
description: Name of the tag (e.g., "Work", "Personal"). Must be unique per user.
|
||||
color:
|
||||
type: string
|
||||
format: hexcolor # Custom format hint, e.g., #FF5733
|
||||
format: hexcolor
|
||||
nullable: true
|
||||
description: Optional color associated with the tag.
|
||||
icon:
|
||||
@ -210,71 +203,68 @@ components:
|
||||
maxLength: 30
|
||||
description: New icon identifier.
|
||||
|
||||
# --- Attachment Info Schema ---
|
||||
AttachmentInfo:
|
||||
type: object
|
||||
description: Metadata about an uploaded attachment.
|
||||
properties:
|
||||
fileId:
|
||||
type: string
|
||||
description: Unique storage identifier/path for the file (used for deletion).
|
||||
fileName:
|
||||
type: string
|
||||
description: Original name of the uploaded file.
|
||||
fileUrl:
|
||||
type: string
|
||||
format: url
|
||||
description: URL to access the uploaded file (e.g., a signed GCS URL).
|
||||
contentType:
|
||||
type: string
|
||||
description: MIME type of the uploaded file.
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Size of the uploaded file in bytes.
|
||||
required:
|
||||
- fileId
|
||||
- fileName
|
||||
- fileUrl
|
||||
- contentType
|
||||
- size
|
||||
|
||||
# --- Todo Schemas ---
|
||||
Todo:
|
||||
type: object
|
||||
description: Represents a Todo item.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
userId:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
description: The ID of the user who owns this Todo.
|
||||
title:
|
||||
type: string
|
||||
description: The main title or task of the Todo.
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional detailed description of the Todo.
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, in-progress, completed]
|
||||
default: pending
|
||||
description: Current status of the Todo item.
|
||||
deadline:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Optional deadline for the Todo item.
|
||||
tagIds: # <-- Added
|
||||
id: { type: string, format: uuid, readOnly: true }
|
||||
userId: { type: string, format: uuid, readOnly: true }
|
||||
title: { type: string }
|
||||
description: { type: string, nullable: true }
|
||||
status: { type: string, enum: [pending, in-progress, completed], default: pending }
|
||||
deadline: { type: string, format: date-time, nullable: true }
|
||||
tagIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: List of IDs of Tags associated with this Todo.
|
||||
default: []
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints.
|
||||
items: { type: string, format: uuid }
|
||||
default: []
|
||||
attachmentUrl: # <-- Changed from attachments array
|
||||
type: string
|
||||
format: url
|
||||
nullable: true
|
||||
description: Publicly accessible URL of the attached image, if any.
|
||||
subtasks:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Subtask'
|
||||
description: List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints.
|
||||
readOnly: true # Subtasks typically managed via their own endpoints
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
items: { $ref: '#/components/schemas/Subtask' }
|
||||
readOnly: true
|
||||
default: []
|
||||
createdAt: { type: string, format: date-time, readOnly: true }
|
||||
updatedAt: { type: string, format: date-time, readOnly: true }
|
||||
required:
|
||||
- id
|
||||
- userId
|
||||
- title
|
||||
- status
|
||||
- tagIds # <-- Added
|
||||
- attachments
|
||||
- tagIds
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
@ -296,7 +286,7 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
tagIds: # <-- Added
|
||||
tagIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@ -308,33 +298,16 @@ components:
|
||||
|
||||
UpdateTodoRequest:
|
||||
type: object
|
||||
description: Data for updating an existing Todo item. All fields are optional for partial updates.
|
||||
description: Data for updating an existing Todo item. Attachment is managed via dedicated endpoints.
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
minLength: 1
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, in-progress, completed]
|
||||
deadline:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
tagIds: # <-- Added
|
||||
title: { type: string, minLength: 1 }
|
||||
description: { type: string, nullable: true }
|
||||
status: { type: string, enum: [pending, in-progress, completed] }
|
||||
deadline: { type: string, format: date-time, nullable: true }
|
||||
tagIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Replace the existing list of associated Tag IDs. IDs must belong to the user.
|
||||
attachments: # Allow updating the list of attachments explicitly
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files.
|
||||
|
||||
items: { type: string, format: uuid }
|
||||
|
||||
# --- Subtask Schemas ---
|
||||
Subtask:
|
||||
type: object
|
||||
@ -392,34 +365,9 @@ components:
|
||||
completed:
|
||||
type: boolean
|
||||
|
||||
# --- File Upload Schemas ---
|
||||
FileUploadResponse:
|
||||
type: object
|
||||
description: Response after successfully uploading a file.
|
||||
properties:
|
||||
fileId:
|
||||
type: string
|
||||
description: Unique identifier for the uploaded file.
|
||||
fileName:
|
||||
type: string
|
||||
description: Original name of the uploaded file.
|
||||
fileUrl:
|
||||
type: string
|
||||
format: url
|
||||
description: URL to access the uploaded file.
|
||||
contentType:
|
||||
type: string
|
||||
description: MIME type of the uploaded file.
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Size of the uploaded file in bytes.
|
||||
required:
|
||||
- fileId
|
||||
- fileName
|
||||
- fileUrl
|
||||
- contentType
|
||||
- size
|
||||
# --- File Upload Response Schema ---
|
||||
FileUploadResponse: # This is the same as AttachmentInfo, could reuse definition with $ref
|
||||
$ref: '#/components/schemas/AttachmentInfo'
|
||||
|
||||
# --- Error Schema ---
|
||||
Error:
|
||||
@ -437,7 +385,6 @@ components:
|
||||
- code
|
||||
- message
|
||||
|
||||
# Reusable Responses
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Invalid input (e.g., validation error, missing fields, invalid tag ID).
|
||||
@ -458,7 +405,7 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
NotFound:
|
||||
description: The requested resource (e.g., Todo, Tag, Subtask) was not found.
|
||||
description: The requested resource (e.g., Todo, Tag, Subtask, Attachment) was not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -476,13 +423,10 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
# Security Requirement applied globally or per-operation
|
||||
# Most endpoints require either Bearer or Cookie auth.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- CookieAuth: []
|
||||
|
||||
# API Path Definitions
|
||||
paths:
|
||||
# --- Authentication Endpoints ---
|
||||
/auth/signup:
|
||||
@ -490,7 +434,7 @@ paths:
|
||||
summary: Register a new user via email/password (API).
|
||||
operationId: signupUserApi
|
||||
tags: [Auth]
|
||||
security: [] # No auth required to sign up
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: User details for registration.
|
||||
@ -508,7 +452,7 @@ paths:
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict" # e.g., Email or Username already exists
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
@ -518,7 +462,7 @@ paths:
|
||||
description: Authenticates a user and returns a JWT access token in the response body for API clients. For browser clients, this endpoint typically also sets an HTTP-only cookie containing the JWT.
|
||||
operationId: loginUserApi
|
||||
tags: [Auth]
|
||||
security: [] # No auth required to log in
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: User credentials for login.
|
||||
@ -534,24 +478,23 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LoginResponse"
|
||||
headers:
|
||||
Set-Cookie: # Indicate that a cookie might be set for browser clients
|
||||
Set-Cookie:
|
||||
schema:
|
||||
type: string
|
||||
description: Contains the JWT authentication cookie (e.g., `jwt_token=...; HttpOnly; Secure; Path=/; SameSite=Lax`)
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized" # Invalid credentials
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
/auth/logout: # Often useful to have an explicit logout
|
||||
/auth/logout:
|
||||
post:
|
||||
summary: Log out the current user.
|
||||
description: Invalidates the current session (e.g., clears the authentication cookie).
|
||||
operationId: logoutUser
|
||||
tags: [Auth]
|
||||
# Requires authentication to know *who* is logging out to clear their session/cookie
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- CookieAuth: []
|
||||
@ -559,12 +502,12 @@ paths:
|
||||
"204":
|
||||
description: Logout successful. No content returned.
|
||||
headers:
|
||||
Set-Cookie: # Indicate that the cookie is being cleared
|
||||
Set-Cookie:
|
||||
schema:
|
||||
type: string
|
||||
description: Clears the JWT authentication cookie (e.g., `jwt_token=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax`)
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized" # If not logged in initially
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
@ -574,7 +517,7 @@ paths:
|
||||
description: Redirects the user's browser to Google's authentication page. Not a typical REST endpoint, part of the web flow.
|
||||
operationId: initiateGoogleLogin
|
||||
tags: [Auth]
|
||||
security: [] # No API auth needed to start the flow
|
||||
security: []
|
||||
responses:
|
||||
"302":
|
||||
description: Redirect to Google's OAuth consent screen. The 'Location' header contains the redirect URL.
|
||||
@ -597,20 +540,7 @@ paths:
|
||||
description: Google redirects the user here after authentication. The server exchanges the received code for tokens, finds/creates the user, generates a JWT, sets the auth cookie, and redirects the user (e.g., to the web app dashboard).
|
||||
operationId: handleGoogleCallback
|
||||
tags: [Auth]
|
||||
security: [] # No API auth needed, Google provides auth code via query param
|
||||
# parameters:
|
||||
# - name: code
|
||||
# in: query
|
||||
# required: true
|
||||
# schema:
|
||||
# type: string
|
||||
# description: Authorization code provided by Google.
|
||||
# - name: state
|
||||
# in: query
|
||||
# required: false # Recommended for security (CSRF protection)
|
||||
# schema:
|
||||
# type: string
|
||||
# description: Opaque value used to maintain state between the request and callback.
|
||||
security: []
|
||||
responses:
|
||||
"302":
|
||||
description: Authentication successful. Redirects the user to the frontend application (e.g., '/dashboard'). Sets auth cookie.
|
||||
@ -680,15 +610,15 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/User"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest" # Validation error
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict" # e.g. Username already taken
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
# --- Tag Endpoints --- <-- New Section
|
||||
# --- Tag Endpoints ---
|
||||
/tags:
|
||||
get:
|
||||
summary: List all tags created by the current user.
|
||||
@ -732,11 +662,11 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Tag'
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest" # Validation error
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict" # Tag name already exists for this user
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
@ -766,7 +696,7 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User does not own this tag
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
@ -793,15 +723,15 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Tag'
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest" # Validation error
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User does not own this tag
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict" # New tag name already exists for this user
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
delete:
|
||||
@ -818,13 +748,12 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User does not own this tag
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
|
||||
# --- Todo Endpoints ---
|
||||
/todos:
|
||||
get:
|
||||
@ -835,59 +764,14 @@ paths:
|
||||
- BearerAuth: []
|
||||
- CookieAuth: []
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [pending, in-progress, completed]
|
||||
description: Filter Todos by status.
|
||||
- name: tagId # <-- Added filter parameter
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Filter Todos by a specific Tag ID.
|
||||
- name: deadline_before
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Filter Todos with deadline before this date/time.
|
||||
- name: deadline_after
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Filter Todos with deadline after this date/time.
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 20
|
||||
description: Maximum number of Todos to return.
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
description: Number of Todos to skip for pagination.
|
||||
- { name: status, in: query, required: false, schema: { type: string, enum: [pending, in-progress, completed] } }
|
||||
- { name: tagId, in: query, required: false, schema: { type: string, format: uuid } }
|
||||
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, default: 20 } }
|
||||
- { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } }
|
||||
responses:
|
||||
"200":
|
||||
description: A list of Todo items matching the criteria.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Todo"
|
||||
description: A list of Todo items.
|
||||
content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Todo" } } } }
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
@ -896,57 +780,35 @@ paths:
|
||||
summary: Create a new Todo item.
|
||||
operationId: createTodo
|
||||
tags: [Todos]
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- CookieAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: Todo item details to create, optionally including Tag IDs.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateTodoRequest" # Now includes tagIds
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/CreateTodoRequest" } } }
|
||||
responses:
|
||||
"201":
|
||||
description: Todo item created successfully. Returns the new Todo.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Todo"
|
||||
description: Todo item created successfully.
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
/todos/{todoId}:
|
||||
parameters: # Parameter applicable to all methods for this path
|
||||
- name: todoId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: ID of the Todo item.
|
||||
parameters:
|
||||
- { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
get:
|
||||
summary: Get a specific Todo item by ID.
|
||||
operationId: getTodoById
|
||||
tags: [Todos]
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- CookieAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: The requested Todo item.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Todo" # Now includes tagIds
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
@ -955,29 +817,19 @@ paths:
|
||||
summary: Update a specific Todo item by ID.
|
||||
operationId: updateTodoById
|
||||
tags: [Todos]
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- CookieAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: Fields of the Todo item to update, potentially including the list of Tag IDs.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateTodoRequest" # Now includes tagIds
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/UpdateTodoRequest" } } }
|
||||
responses:
|
||||
"200":
|
||||
description: Todo item updated successfully. Returns the updated Todo.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Todo"
|
||||
description: Todo item updated successfully.
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
@ -995,7 +847,7 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
@ -1004,62 +856,44 @@ paths:
|
||||
# --- Attachment Endpoints ---
|
||||
/todos/{todoId}/attachments:
|
||||
parameters:
|
||||
- name: todoId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: ID of the Todo item to attach the file to.
|
||||
- { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
post:
|
||||
summary: Upload a file and attach it to a Todo item.
|
||||
operationId: uploadTodoAttachment
|
||||
summary: Upload or replace the image attachment for a Todo item.
|
||||
operationId: uploadOrReplaceTodoAttachment # Renamed for clarity
|
||||
tags: [Attachments, Todos]
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- CookieAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: The file to upload.
|
||||
description: The image file to upload.
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file: # Name of the form field for the file
|
||||
type: string
|
||||
format: binary
|
||||
required:
|
||||
- file
|
||||
# You might add examples or encoding details here if needed
|
||||
properties: { file: { type: string, format: binary } }
|
||||
required: [file]
|
||||
responses:
|
||||
"201":
|
||||
description: File uploaded and attached successfully. Returns file details. The Todo's `attachments` array is updated server-side.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FileUploadResponse'
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest" # e.g., No file, size limit exceeded, invalid file type
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound" # Todo not found
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError" # File storage error, etc.
|
||||
"201": # Use 201 Created (or 200 OK if replacing)
|
||||
description: Image uploaded/replaced successfully. Returns file details.
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/FileUploadResponse' } } } # Reusing this schema
|
||||
"400": { $ref: "#/components/responses/BadRequest" } # Invalid file type, size limit etc.
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" } # Todo not found
|
||||
"500": { $ref: "#/components/responses/InternalServerError" }
|
||||
delete:
|
||||
summary: Delete the image attachment from a Todo item.
|
||||
operationId: deleteTodoAttachment # Reused name is fine
|
||||
tags: [Attachments, Todos]
|
||||
responses:
|
||||
"204": { description: Attachment deleted successfully. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" } # Todo or attachment not found
|
||||
"500": { $ref: "#/components/responses/InternalServerError" }
|
||||
|
||||
# --- Subtask Endpoints ---
|
||||
/todos/{todoId}/subtasks:
|
||||
parameters:
|
||||
- name: todoId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: ID of the parent Todo item.
|
||||
- { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
get:
|
||||
summary: List all subtasks for a specific Todo item.
|
||||
operationId: listSubtasksForTodo
|
||||
@ -1079,9 +913,9 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound" # Parent Todo not found
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
post:
|
||||
@ -1110,9 +944,9 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound" # Parent Todo not found
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
@ -1158,9 +992,9 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound" # Todo or Subtask not found
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
delete:
|
||||
@ -1176,8 +1010,8 @@ paths:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound" # Todo or Subtask not found
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
$ref: "#/components/responses/InternalServerError"
|
||||
|
||||
@ -100,4 +100,5 @@ export const Icons = {
|
||||
loader: IconLoader,
|
||||
circle: IconCircle,
|
||||
moreVertical: IconDotsVertical,
|
||||
file: IconFile,
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
// import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@ -15,45 +15,45 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
// import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
const [notificationCount, setNotificationCount] = useState(3);
|
||||
// const [notificationCount, setNotificationCount] = useState(3);
|
||||
|
||||
const notifications = [
|
||||
{
|
||||
id: 1,
|
||||
title: "New task assigned",
|
||||
description: "You have been assigned a new task",
|
||||
time: "5 minutes ago",
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Task completed",
|
||||
description: "Your task 'Update documentation' has been completed",
|
||||
time: "1 hour ago",
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Meeting reminder",
|
||||
description: "Team meeting starts in 30 minutes",
|
||||
time: "2 hours ago",
|
||||
read: false,
|
||||
},
|
||||
];
|
||||
// const notifications = [
|
||||
// {
|
||||
// id: 1,
|
||||
// title: "New task assigned",
|
||||
// description: "You have been assigned a new task",
|
||||
// time: "5 minutes ago",
|
||||
// read: false,
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// title: "Task completed",
|
||||
// description: "Your task 'Update documentation' has been completed",
|
||||
// time: "1 hour ago",
|
||||
// read: false,
|
||||
// },
|
||||
// {
|
||||
// id: 3,
|
||||
// title: "Meeting reminder",
|
||||
// description: "Team meeting starts in 30 minutes",
|
||||
// time: "2 hours ago",
|
||||
// read: false,
|
||||
// },
|
||||
// ];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const markAsRead = (id: number) => {
|
||||
setNotificationCount(Math.max(0, notificationCount - 1));
|
||||
};
|
||||
// const markAsRead = (id: number) => {
|
||||
// setNotificationCount(Math.max(0, notificationCount - 1));
|
||||
// };
|
||||
|
||||
const markAllAsRead = () => {
|
||||
setNotificationCount(0);
|
||||
};
|
||||
// const markAllAsRead = () => {
|
||||
// setNotificationCount(0);
|
||||
// };
|
||||
|
||||
return (
|
||||
<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 className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
{/* Notifications Dropdown */}
|
||||
<DropdownMenu>
|
||||
{/* <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -173,7 +173,7 @@ export function Navbar() {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu> */}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -26,6 +20,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { TodoForm } from "@/components/todo-form";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -35,8 +30,9 @@ import Image from "next/image";
|
||||
interface TodoCardProps {
|
||||
todo: Todo;
|
||||
tags: Tag[];
|
||||
onUpdate: (todo: Partial<Todo>) => void;
|
||||
onUpdate: (todo: Partial<Todo>) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
onAttachmentsChanged?: (attachments: string[]) => void;
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
@ -45,6 +41,7 @@ export function TodoCard({
|
||||
tags,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onAttachmentsChanged,
|
||||
isDraggable = false,
|
||||
}: TodoCardProps) {
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
@ -69,7 +66,7 @@ export function TodoCard({
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
// Style helpers
|
||||
// --- Helper Functions ---
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
@ -82,175 +79,175 @@ export function TodoCard({
|
||||
return "border-l-4 border-l-slate-400";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Icons.clock className="h-5 w-5 text-amber-500" />;
|
||||
case "in-progress":
|
||||
return <Icons.loader className="h-5 w-5 text-sky-500" />;
|
||||
case "completed":
|
||||
return <Icons.checkSquare className="h-5 w-5 text-emerald-500" />;
|
||||
default:
|
||||
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 getStatusIcon = (status: string) => {
|
||||
// switch (status) {
|
||||
// case "pending":
|
||||
// return <Icons.clock className="h-5 w-5 text-amber-500" />;
|
||||
// case "in-progress":
|
||||
// return <Icons.loader className="h-5 w-5 text-sky-500" />;
|
||||
// case "completed":
|
||||
// return <Icons.checkSquare className="h-5 w-5 text-emerald-500" />;
|
||||
// default:
|
||||
// return <Icons.circle className="h-5 w-5 text-slate-400" />;
|
||||
// }
|
||||
// };
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return "";
|
||||
const date = new Date(dateString);
|
||||
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 (
|
||||
<>
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
"transition-shadow duration-150 ease-out",
|
||||
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" : "",
|
||||
isDragging ? "shadow-lg" : ""
|
||||
isDragging
|
||||
? "shadow-lg ring-2 ring-primary ring-opacity-50 scale-105 z-10"
|
||||
: ""
|
||||
)}
|
||||
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
||||
onClick={() => !isDraggable && setIsViewDialogOpen(true)}
|
||||
>
|
||||
<CardContent className="p-4 flex gap-4">
|
||||
{/* Left icon, like notification card */}
|
||||
<div className="flex-shrink-0 mt-1">{getStatusIcon(todo.status)}</div>
|
||||
{/* Main content */}
|
||||
<div className="flex-grow">
|
||||
<CardHeader className="p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"text-base",
|
||||
todo.status === "completed" &&
|
||||
"line-through text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{todo.title}
|
||||
</CardTitle>
|
||||
{!isDraggable && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
aria-label="More actions"
|
||||
>
|
||||
<Icons.moreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsViewDialogOpen(true)}
|
||||
>
|
||||
<Icons.eye className="h-4 w-4 mr-2" />
|
||||
View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
>
|
||||
<Icons.edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Icons.trash className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions and deadline on a new line */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{todo.deadline && (
|
||||
<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
|
||||
<CardContent className="p-3">
|
||||
{/* Optional Cover Image */}
|
||||
{coverImage && (
|
||||
<div className="relative h-24 w-full mb-2 rounded overflow-hidden">
|
||||
<Image
|
||||
src={coverImage || "/placeholder.svg"}
|
||||
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
|
||||
className={cn(
|
||||
"mt-1",
|
||||
"text-sm font-medium leading-snug pr-2",
|
||||
todo.status === "completed" &&
|
||||
"line-through text-muted-foreground/70"
|
||||
"line-through text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{todo.description}
|
||||
</CardDescription>
|
||||
{/* Tags and indicators */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{todoTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 h-4 text-[10px] font-normal border-0"
|
||||
style={{
|
||||
backgroundColor: `${tag.color}20` || "#FF5A5F20",
|
||||
color: tag.color || "#FF5A5F",
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{hasSubtasks && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||
<Icons.checkSquare className="h-3 w-3" />
|
||||
{completedSubtasks}/{todo.subtasks.length}
|
||||
</span>
|
||||
)}
|
||||
{hasAttachments && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||
<Icons.paperclip className="h-3 w-3" />
|
||||
{todo.attachments.length}
|
||||
</span>
|
||||
)}
|
||||
{hasImage && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||
<Icons.image className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Bottom row: created date and status toggle */}
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{todo.createdAt ? formatDate(todo.createdAt) : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant={todo.status === "completed" ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 py-0 rounded-full"
|
||||
onClick={handleStatusToggle}
|
||||
{todo.title}
|
||||
</CardTitle>
|
||||
{!isDraggable && (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => open && setIsViewDialogOpen(false)}
|
||||
>
|
||||
{todo.status === "completed" ? (
|
||||
<Icons.x className="h-3 w-3" />
|
||||
) : (
|
||||
<Icons.check className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0 -mt-1 -mr-1"
|
||||
>
|
||||
<Icons.moreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setIsViewDialogOpen(true)}>
|
||||
<Icons.eye className="h-3.5 w-3.5 mr-2" /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setIsEditDialogOpen(true)}>
|
||||
<Icons.edit className="h-3.5 w-3.5 mr-2" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||
<Icons.trash className="h-3.5 w-3.5 mr-2" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{/* Description (optional, truncated) */}
|
||||
{todo.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2">
|
||||
{todo.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Badges and Indicators */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[10px] mb-1.5">
|
||||
{todoTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="outline"
|
||||
className="px-1.5 py-0 h-4 text-[10px] font-normal border-0"
|
||||
style={{
|
||||
backgroundColor: `${tag.color}20` || "#FF5A5F20",
|
||||
color: tag.color || "#FF5A5F",
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{hasSubtasks && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||
<Icons.checkSquare className="h-3 w-3" />
|
||||
{completedSubtasks}/{todo.subtasks.length}
|
||||
</span>
|
||||
)}
|
||||
{hasAttachments && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||
<Icons.paperclip className="h-3 w-3" />1
|
||||
</span>
|
||||
)}
|
||||
{todo.deadline && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground ml-2">
|
||||
<Icons.calendar className="h-3 w-3" />
|
||||
{formatDate(todo.deadline)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Bottom Row: Created Date & Status Toggle */}
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{todo.createdAt ? formatDate(todo.createdAt) : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant={todo.status === "completed" ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
className="h-5 px-1.5 py-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusToggle();
|
||||
}}
|
||||
>
|
||||
{todo.status === "completed" ? (
|
||||
<Icons.x className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<Icons.check className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -261,17 +258,17 @@ export function TodoCard({
|
||||
<DialogHeader className="space-y-1">
|
||||
<DialogTitle className="text-xl">Edit Todo</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<TodoForm
|
||||
todo={todo}
|
||||
tags={tags}
|
||||
onSubmit={(updatedTodo) => {
|
||||
onUpdate(updatedTodo);
|
||||
onSubmit={async (updatedTodoData) => {
|
||||
await onUpdate(updatedTodoData);
|
||||
setIsEditDialogOpen(false);
|
||||
toast.success("Todo updated successfully");
|
||||
}}
|
||||
onAttachmentsChanged={onAttachmentsChanged}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -283,7 +280,7 @@ export function TodoCard({
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge
|
||||
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"
|
||||
? "bg-amber-50 text-amber-700"
|
||||
: todo.status === "in-progress"
|
||||
@ -295,14 +292,8 @@ export function TodoCard({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-2.5 h-2.5 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"
|
||||
"w-2 h-2 rounded-full mr-1.5",
|
||||
getStatusColor(todo.status).replace("border-l-4 ", "bg-")
|
||||
)}
|
||||
/>
|
||||
{todo.status.replace("-", " ")}
|
||||
@ -324,10 +315,11 @@ export function TodoCard({
|
||||
Created {formatDate(todo.createdAt)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{hasImage && (
|
||||
<div className="w-full h-48 overflow-hidden relative">
|
||||
{/* Cover Image */}
|
||||
{coverImage && (
|
||||
<div className="w-full h-48 overflow-hidden relative bg-muted">
|
||||
<Image
|
||||
src={todo.image || "/placeholder.svg?height=192&width=450"}
|
||||
src={coverImage || "/placeholder.svg"}
|
||||
alt={todo.title}
|
||||
fill
|
||||
style={{ objectFit: "cover" }}
|
||||
@ -336,15 +328,20 @@ export function TodoCard({
|
||||
/>
|
||||
</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 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{todo.description}
|
||||
</div>
|
||||
)}
|
||||
{/* Tags */}
|
||||
{todoTags.length > 0 && (
|
||||
<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">
|
||||
{todoTags.map((tag) => (
|
||||
<Badge
|
||||
@ -362,26 +359,11 @@ export function TodoCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasAttachments && (
|
||||
<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>
|
||||
)}
|
||||
{/* Subtasks */}
|
||||
{hasSubtasks && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium mb-1.5">
|
||||
Subtasks ({completedSubtasks}/{todo.subtasks.length})
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
SUBTASKS ({completedSubtasks}/{todo.subtasks.length})
|
||||
</h4>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{todo.subtasks.map((subtask) => (
|
||||
@ -413,8 +395,42 @@ export function TodoCard({
|
||||
</ul>
|
||||
</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>
|
||||
<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>
|
||||
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t px-6 py-3 bg-muted/30 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -429,7 +445,7 @@ export function TodoCard({
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
<Icons.edit className="h-3.5 w-3.5 mr-1.5" /> Edit Todo
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { uploadAttachment, deleteAttachment } from "@/services/api-attachments";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@ -16,25 +17,33 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MultiSelect } from "@/components/multi-select";
|
||||
import { Icons } from "@/components/icons";
|
||||
import type { Todo, Tag } from "@/services/api-types";
|
||||
import { Progress } from "./ui/progress";
|
||||
|
||||
interface TodoFormProps {
|
||||
todo?: Todo;
|
||||
tags: Tag[];
|
||||
onSubmit: (todo: Partial<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>>({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "pending",
|
||||
deadline: undefined,
|
||||
tagIds: [],
|
||||
image: null,
|
||||
attachmentUrl: null,
|
||||
});
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (todo) {
|
||||
@ -44,12 +53,17 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
|
||||
status: todo.status,
|
||||
deadline: todo.deadline,
|
||||
tagIds: todo.tagIds || [],
|
||||
image: todo.image || null,
|
||||
attachmentUrl: todo.attachmentUrl || null,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "pending",
|
||||
deadline: undefined,
|
||||
tagIds: [],
|
||||
attachmentUrl: null,
|
||||
});
|
||||
|
||||
if (todo.image) {
|
||||
setImagePreview(todo.image);
|
||||
}
|
||||
}
|
||||
}, [todo]);
|
||||
|
||||
@ -68,29 +82,85 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
|
||||
setFormData((prev) => ({ ...prev, tagIds: selected }));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<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];
|
||||
if (!file) return;
|
||||
// Only allow images
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Only image files are allowed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// In a real app, we would upload the file to a server and get a URL back
|
||||
// For now, we'll create a local object URL
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
setImagePreview(imageUrl);
|
||||
setFormData((prev) => ({ ...prev, image: imageUrl }));
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// Simulate progress for demo – replace if backend supports it
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress((p) => Math.min(p + 10, 90));
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
const response = await uploadAttachment(todo.id, file, token);
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
toast.success(`Uploaded: "${response.fileName}"`);
|
||||
|
||||
setFormData((f) => ({ ...f, attachmentUrl: response.fileUrl }));
|
||||
onAttachmentsChanged?.([response.fileUrl]);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
clearInterval(progressInterval);
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
console.error(err);
|
||||
toast.error(
|
||||
`Upload failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
||||
);
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImagePreview(null);
|
||||
setFormData((prev) => ({ ...prev, image: null }));
|
||||
// Remove an existing attachment
|
||||
const handleRemoveAttachment = async (attachmentId: string) => {
|
||||
// Only one attachment is supported, so attachmentId is ignored
|
||||
|
||||
if (!todo || !token) {
|
||||
toast.error("Cannot remove attachments right now.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAttachment(todo.id, attachmentId, token);
|
||||
// Only one attachment is supported now, so just clear the attachmentUrl
|
||||
setFormData((f) => ({ ...f, attachmentUrl: null }));
|
||||
onAttachmentsChanged?.([]);
|
||||
toast.success("Attachment removed.");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(
|
||||
`Delete failed: ${err instanceof Error ? err.message : "Unknown error"}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Title, Description, Status, Deadline, Tags... */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
@ -150,46 +220,50 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
|
||||
placeholder="Select tags"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Image (optional)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Attachment Section - Only shown when editing an existing todo */}
|
||||
{todo && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attachments">Attachments</Label>
|
||||
<Input
|
||||
id="image"
|
||||
name="image"
|
||||
id="file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
className="flex-1"
|
||||
multiple={false}
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{imagePreview && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRemoveImage}
|
||||
>
|
||||
<Icons.trash className="h-4 w-4" />
|
||||
<span className="sr-only">Remove image</span>
|
||||
</Button>
|
||||
{isUploading && (
|
||||
<Progress value={uploadProgress} className="w-full h-2" />
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#FF5A5F] hover:bg-[#FF5A5F]/90"
|
||||
>
|
||||
{todo ? "Update" : "Create"} Todo
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
View Attachment
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveAttachment("")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full h-10" disabled={isUploading}>
|
||||
{isUploading ? "Uploading..." : todo ? "Update Todo" : "Create Todo"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -1,56 +1,116 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { TodoForm } from "@/components/todo-form"
|
||||
import { Icons } from "@/components/icons"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Todo, Tag } from "@/services/api-types"
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import Image from "next/image"; // Import Image
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { deleteAttachment } from "@/services/api-attachments";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"; // Import AlertDialog
|
||||
import { TodoForm } from "@/components/todo-form";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Todo, Tag } from "@/services/api-types";
|
||||
|
||||
interface TodoRowProps {
|
||||
todo: Todo
|
||||
tags: Tag[]
|
||||
onUpdate: (todo: Partial<Todo>) => void
|
||||
onDelete: () => void
|
||||
isDraggable?: boolean
|
||||
todo: Todo;
|
||||
tags: Tag[];
|
||||
onUpdate: (todo: Partial<Todo>) => Promise<void>; // Make async
|
||||
onDelete: () => void;
|
||||
onAttachmentsChanged?: (attachments: AttachmentInfo[]) => void; // Add callback
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }: TodoRowProps) {
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
|
||||
export function TodoRow({
|
||||
todo,
|
||||
tags,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onAttachmentsChanged,
|
||||
isDraggable = false,
|
||||
}: TodoRowProps) {
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
|
||||
const { token } = useAuth();
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: todo.id,
|
||||
disabled: !isDraggable,
|
||||
})
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
};
|
||||
|
||||
const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id))
|
||||
const todoTags = tags.filter((tag) => todo.tagIds?.includes(tag.id));
|
||||
const hasAttachments = todo.attachments && todo.attachments.length > 0;
|
||||
|
||||
const handleStatusToggle = () => {
|
||||
const newStatus = todo.status === "completed" ? "pending" : "completed"
|
||||
onUpdate({ status: newStatus })
|
||||
}
|
||||
const newStatus = todo.status === "completed" ? "pending" : "completed";
|
||||
onUpdate({ status: newStatus });
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return null
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Check if todo has image or attachments
|
||||
const hasAttachments = todo.attachments && todo.attachments.length > 0
|
||||
const handleDeleteAttachment = async (attachmentId: string) => {
|
||||
if (!token) {
|
||||
toast.error("Authentication required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAttachment(todo.id, attachmentId, token);
|
||||
toast.success("Attachment deleted.");
|
||||
const updatedAttachments = todo.attachments.filter(
|
||||
(att) => att.fileId !== attachmentId
|
||||
);
|
||||
onAttachmentsChanged?.(updatedAttachments);
|
||||
// Also update the local state for the dialog if it's open
|
||||
setFormData((prev) => ({ ...prev, attachments: updatedAttachments }));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete attachment:", error);
|
||||
toast.error("Failed to delete attachment.");
|
||||
}
|
||||
};
|
||||
|
||||
// State to manage form data within the row component context if needed, e.g., for the view dialog
|
||||
const [formData, setFormData] = useState(todo);
|
||||
useEffect(() => {
|
||||
setFormData(todo);
|
||||
}, [todo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -61,170 +121,387 @@ export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }:
|
||||
"group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 rounded-md transition-colors",
|
||||
todo.status === "completed" ? "text-muted-foreground" : "",
|
||||
isDraggable ? "cursor-grab active:cursor-grabbing" : "",
|
||||
isDragging ? "shadow-lg bg-muted" : "",
|
||||
isDragging ? "shadow-lg bg-muted" : ""
|
||||
)}
|
||||
{...(isDraggable ? { ...attributes, ...listeners } : {})}
|
||||
>
|
||||
<Checkbox
|
||||
checked={todo.status === "completed"}
|
||||
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">
|
||||
<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}
|
||||
</span>
|
||||
{todo.image && <Icons.image className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{hasAttachments && <Icons.paperclip className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{/* Indicators */}
|
||||
<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">{todo.description}</p>}
|
||||
{todo.description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{todo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Right-aligned section */}
|
||||
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
{" "}
|
||||
{/* Added ml-auto and flex-shrink-0 */}
|
||||
{/* Tags */}
|
||||
{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) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsViewDialogOpen(true)} className="h-7 w-7">
|
||||
<Icons.eye className="h-3.5 w-3.5" />
|
||||
{/* Action Buttons (Appear on Hover) */}
|
||||
<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"
|
||||
>
|
||||
<Icons.eye className="h-3.5 w-3.5" />{" "}
|
||||
<span className="sr-only">View</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsEditDialogOpen(true)} className="h-7 w-7">
|
||||
<Icons.edit className="h-3.5 w-3.5" />
|
||||
<Button
|
||||
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>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onDelete} className="h-7 w-7">
|
||||
<Icons.trash className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
{/* Delete Confirmation */}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Todo</DialogTitle>
|
||||
<DialogDescription>Update your todo details</DialogDescription>
|
||||
<DialogDescription>
|
||||
Update your todo details and attachments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TodoForm
|
||||
todo={todo}
|
||||
tags={tags}
|
||||
onSubmit={(updatedTodo) => {
|
||||
onUpdate(updatedTodo)
|
||||
setIsEditDialogOpen(false)
|
||||
toast.success("Todo updated successfully")
|
||||
onSubmit={async (updatedTodoData) => {
|
||||
await onUpdate(updatedTodoData);
|
||||
setIsEditDialogOpen(false);
|
||||
}}
|
||||
onAttachmentsChanged={(newAttachments) => {
|
||||
// If the parent needs immediate notification of attachment changes
|
||||
onAttachmentsChanged?.(newAttachments);
|
||||
// Optionally update local state if needed within this component context
|
||||
setFormData((prev) => ({ ...prev, attachments: newAttachments }));
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* View Dialog */}
|
||||
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{todo.title}</DialogTitle>
|
||||
<DialogDescription>Created on {new Date(todo.createdAt).toLocaleDateString()}</DialogDescription>
|
||||
{/* Re-use the View Dialog structure from TodoCard, adapting as needed */}
|
||||
<DialogContent className="sm:max-w-[450px] p-0 overflow-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
{/* ... 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>
|
||||
{todo.image && (
|
||||
<div className="w-full h-48 overflow-hidden rounded-md mb-4">
|
||||
<img
|
||||
src={todo.image || "/placeholder.svg?height=192&width=448"}
|
||||
alt={todo.title}
|
||||
className="w-full h-full object-cover"
|
||||
|
||||
{/* Cover Image (find first image attachment) */}
|
||||
{formData.attachments?.find((att) =>
|
||||
att.contentType.startsWith("image/")
|
||||
) && (
|
||||
<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 className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Status</h4>
|
||||
<Badge
|
||||
className={cn(
|
||||
"text-white",
|
||||
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 className="px-6 py-4 space-y-4 max-h-[50vh] overflow-y-auto">
|
||||
{/* Description */}
|
||||
{formData.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formData.description}
|
||||
</div>
|
||||
)}
|
||||
{/* Tags */}
|
||||
{todoTags.length > 0 && (
|
||||
<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) => (
|
||||
<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}
|
||||
</Badge>
|
||||
))}
|
||||
</div>{" "}
|
||||
</div>
|
||||
)}
|
||||
{/* Subtasks */}
|
||||
{formData.subtasks && formData.subtasks.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
SUBTASKS (
|
||||
{formData.subtasks.filter((s) => s.completed).length}/
|
||||
{formData.subtasks.length})
|
||||
</h4>
|
||||
{/* ... Subtask list rendering ... */}
|
||||
</div>
|
||||
)}
|
||||
{/* Attachments */}
|
||||
{(formData.attachments?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
ATTACHMENTS
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{formData.attachments.map((att) => (
|
||||
<div
|
||||
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>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasAttachments && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Attachments</h4>
|
||||
<ul className="space-y-2">
|
||||
{todo.attachments.map((attachment, index) => (
|
||||
<li key={index} className="flex items-center gap-2 text-sm">
|
||||
<Icons.paperclip className="h-4 w-4" />
|
||||
<span>{attachment}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{todo.subtasks && todo.subtasks.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Subtasks</h4>
|
||||
<ul className="space-y-2">
|
||||
{todo.subtasks.map((subtask) => (
|
||||
<li key={subtask.id} className="flex items-center gap-2 text-sm">
|
||||
<Icons.check className={`h-4 w-4 ${subtask.completed ? "text-green-500" : "text-gray-300"}`} />
|
||||
<span className={subtask.completed ? "line-through text-muted-foreground" : ""}>
|
||||
{subtask.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ export function useTags() {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tags"],
|
||||
queryFn: () => listUserTags(token),
|
||||
queryFn: () => listUserTags(token || undefined),
|
||||
enabled: !!token,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "storage.googleapis.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
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 = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1"
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1";
|
||||
|
||||
// Request options with authentication token if available
|
||||
const getRequestOptions = (token?: string | null) => {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
const getRequestOptions = (token?: string | null, isFormData = false) => {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (!isFormData) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return { headers }
|
||||
}
|
||||
return { headers };
|
||||
};
|
||||
|
||||
// Generic fetch function with error handling
|
||||
export async function apiFetch<T>(
|
||||
async function apiFetch<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
token?: string | null,
|
||||
simulateDelay = process.env.NODE_ENV === "development" ? 300 : 0, // Only simulate delay in development
|
||||
isFormData = false,
|
||||
simulateDelay = process.env.NODE_ENV === "development" ? 300 : 0
|
||||
): Promise<T> {
|
||||
if (simulateDelay > 0) {
|
||||
await delay(simulateDelay)
|
||||
await delay(simulateDelay);
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`
|
||||
const url = `${API_BASE_URL}${
|
||||
endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||
}`;
|
||||
|
||||
// Merge headers carefully
|
||||
const baseOptions = getRequestOptions(token, isFormData);
|
||||
const requestOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
...getRequestOptions(token).headers,
|
||||
...options.headers,
|
||||
...baseOptions.headers, // Use headers from getRequestOptions
|
||||
...options.headers, // Allow overriding/adding headers from options
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, requestOptions)
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
// Handle non-2xx responses
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `API error: ${response.status}`)
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.message || `API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
// Handle empty responses (like for DELETE operations)
|
||||
const contentType = response.headers.get("content-type")
|
||||
// Handle empty responses (like 204 No Content)
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
return (await response.json()) as T
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
return {} as T
|
||||
// Handle other content types if necessary, or return raw response?
|
||||
// For now, assume JSON or empty for successful non-error responses
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
// Convenience methods for common HTTP methods
|
||||
export const apiClient = {
|
||||
get: <T>(endpoint: string, token?: string | null) =>
|
||||
apiFetch<T>(endpoint, { method: 'GET' }, token),
|
||||
|
||||
post: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||
apiFetch<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
}, token),
|
||||
|
||||
put: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||
apiFetch<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
}, token),
|
||||
|
||||
patch: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||
apiFetch<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
}, token),
|
||||
|
||||
delete: <T>(endpoint: string, token?: string | null) =>
|
||||
apiFetch<T>(endpoint, { method: 'DELETE' }, token),
|
||||
// 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;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: <T>(endpoint: string, token?: string | null) =>
|
||||
apiFetch<T>(endpoint, { method: "GET" }, token),
|
||||
|
||||
post: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||
apiFetch<T>(
|
||||
endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
token
|
||||
),
|
||||
|
||||
put: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||
apiFetch<T>(
|
||||
endpoint,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
token
|
||||
),
|
||||
|
||||
patch: <T>(endpoint: string, data: unknown, token?: string | null) =>
|
||||
apiFetch<T>(
|
||||
endpoint,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
token
|
||||
),
|
||||
|
||||
delete: <T>(endpoint: string, token?: string | null) =>
|
||||
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 {
|
||||
id: string
|
||||
username: string
|
||||
@ -59,8 +57,7 @@ export interface Todo {
|
||||
status: "pending" | "in-progress" | "completed"
|
||||
deadline?: string | null
|
||||
tagIds: string[]
|
||||
attachments: string[]
|
||||
image?: string | null
|
||||
attachmentUrl?: string | null
|
||||
subtasks: Subtask[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@ -72,7 +69,6 @@ export interface CreateTodoRequest {
|
||||
status?: "pending" | "in-progress" | "completed"
|
||||
deadline?: string | null
|
||||
tagIds?: string[]
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateTodoRequest {
|
||||
@ -81,8 +77,7 @@ export interface UpdateTodoRequest {
|
||||
status?: "pending" | "in-progress" | "completed"
|
||||
deadline?: string | null
|
||||
tagIds?: string[]
|
||||
attachments?: string[]
|
||||
image?: string | null
|
||||
// attachments are managed via separate endpoints, removed from here
|
||||
}
|
||||
|
||||
export interface Subtask {
|
||||
@ -103,15 +98,15 @@ export interface UpdateSubtaskRequest {
|
||||
completed?: boolean
|
||||
}
|
||||
|
||||
export interface FileUploadResponse {
|
||||
fileId: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
contentType: string
|
||||
size: number
|
||||
export type FileUploadResponse = {
|
||||
fileId: string // Identifier used for deletion (e.g., GCS object path)
|
||||
fileName: string // Original filename
|
||||
fileUrl: string // Publicly accessible URL (e.g., signed URL)
|
||||
contentType: string // MIME type
|
||||
size: number // Size in bytes
|
||||
}
|
||||
|
||||
export interface Error {
|
||||
code: number
|
||||
message: string
|
||||
code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user