feat: store attachment

This commit is contained in:
Sosokker 2025-04-21 00:10:30 +07:00
parent f4bc48c337
commit 1ddffbc026
30 changed files with 1622 additions and 1422 deletions

View File

@ -73,18 +73,14 @@ test:
migrate-up: migrate-up:
@echo ">> Applying migrations..." @echo ">> Applying migrations..."
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) up $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) up
migrate-down: migrate-down:
@echo ">> Rolling back last migration..." @echo ">> Rolling back last migration..."
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) down 1 $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) down 1
migrate-force: migrate-force:
@echo ">> Forcing migration version $(VERSION)..." @echo ">> Forcing migration version $(VERSION)..."
@if [ -z "$(DB_URL)" ]; then echo "Error: DB_URL is not set. Run 'make docker-db' or set it manually."; exit 1; fi
@if [ -z "$(VERSION)" ]; then echo "Error: VERSION is not set. Usage: make migrate-force VERSION=<version_number>"; exit 1; fi
$(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION) $(MIGRATE) -database "$(DB_URL)" -path $(MIGRATIONS_PATH) force $(VERSION)
clean: clean:

View File

@ -56,14 +56,7 @@ func main() {
repoRegistry := repository.NewRepositoryRegistry(pool) repoRegistry := repository.NewRepositoryRegistry(pool)
var storageService service.FileStorageService var storageService service.FileStorageService
switch cfg.Storage.Type { storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger)
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)
}
if err != nil { if err != nil {
logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type) logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type)
os.Exit(1) os.Exit(1)

View File

@ -7,6 +7,9 @@ server:
idleTimeout: 60s idleTimeout: 60s
basePath: "/api/v1" # Matches OpenAPI server URL basePath: "/api/v1" # Matches OpenAPI server URL
frontend:
url: "http://localhost:3000"
database: database:
url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod url: "postgresql://postgres:@localhost:5433/postgres?sslmode=disable" # Use env vars in prod
@ -39,7 +42,5 @@ cache:
cleanupInterval: 10m cleanupInterval: 10m
storage: storage:
local: bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME
path: "/" # gcs: credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS
# bucketName: "your-gcs-bucket-name" # Env: STORAGE_GCS_BUCKETNAME
# credentialsFile: "/path/to/gcs-credentials.json" # Env: GOOGLE_APPLICATION_CREDENTIALS

View File

@ -26,6 +26,7 @@ tool (
) )
require ( require (
cloud.google.com/go/storage v1.51.0
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
@ -37,6 +38,7 @@ require (
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.37.0 golang.org/x/crypto v0.37.0
golang.org/x/oauth2 v0.29.0 golang.org/x/oauth2 v0.29.0
google.golang.org/api v0.229.0
) )
require ( require (
@ -47,7 +49,6 @@ require (
cloud.google.com/go/compute/metadata v0.6.0 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/iam v1.4.1 // indirect cloud.google.com/go/iam v1.4.1 // indirect
cloud.google.com/go/monitoring v1.24.0 // indirect cloud.google.com/go/monitoring v1.24.0 // indirect
cloud.google.com/go/storage v1.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
@ -114,7 +115,6 @@ require (
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.24.0 // indirect golang.org/x/tools v0.24.0 // indirect
google.golang.org/api v0.229.0 // indirect
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect

View File

@ -1,6 +1,5 @@
cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4=
cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=
cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=
cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU=
@ -11,16 +10,24 @@ cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM=
cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q=
cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY=
cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM=
cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc=
cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw=
cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc=
cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE=
cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@ -58,8 +65,11 @@ github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -113,12 +123,16 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
@ -209,8 +223,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
@ -255,24 +269,18 @@ go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//sn
go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
@ -295,8 +303,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
@ -343,7 +349,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8= google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8=
google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0= google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=

View File

@ -139,7 +139,7 @@ func mapDomainTagToApi(tag *domain.Tag) *models.Tag {
UpdatedAt: &updatedAt} UpdatedAt: &updatedAt}
} }
func mapDomainTodoToApi(todo *domain.Todo) *models.Todo { func mapDomainTodoToApi(todo *domain.Todo, attachmentInfos []models.AttachmentInfo) *models.Todo { // Takes AttachmentInfo now
if todo == nil { if todo == nil {
return nil return nil
} }
@ -162,17 +162,18 @@ func mapDomainTodoToApi(todo *domain.Todo) *models.Todo {
updatedAt := todo.UpdatedAt updatedAt := todo.UpdatedAt
return &models.Todo{ return &models.Todo{
Id: &todoID, Id: &todoID,
UserId: &userID, UserId: &userID,
Title: todo.Title, Title: todo.Title,
Description: todo.Description, Description: todo.Description,
Status: models.TodoStatus(todo.Status), Status: models.TodoStatus(todo.Status),
Deadline: todo.Deadline, Deadline: todo.Deadline,
TagIds: tagIDs, TagIds: tagIDs,
Attachments: todo.Attachments, AttachmentUrl: todo.AttachmentUrl,
Subtasks: &apiSubtasks, Subtasks: &apiSubtasks,
CreatedAt: &createdAt, CreatedAt: &createdAt,
UpdatedAt: &updatedAt} UpdatedAt: &updatedAt,
}
} }
func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask { func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
@ -193,11 +194,11 @@ func mapDomainSubtaskToApi(subtask *domain.Subtask) *models.Subtask {
UpdatedAt: &updatedAt} UpdatedAt: &updatedAt}
} }
func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.FileUploadResponse { func mapDomainAttachmentInfoToApi(info *domain.AttachmentInfo) *models.AttachmentInfo {
if info == nil { if info == nil {
return nil return nil
} }
return &models.FileUploadResponse{ return &models.AttachmentInfo{
FileId: info.FileID, FileId: info.FileID,
FileName: info.FileName, FileName: info.FileName,
FileUrl: info.FileURL, FileUrl: info.FileURL,
@ -577,6 +578,8 @@ func (h *ApiHandler) DeleteTagById(w http.ResponseWriter, r *http.Request, tagId
// --- Todo Handlers --- // --- Todo Handlers ---
// CreateTodo remains the same
func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) { func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
userID, err := GetUserIDFromContext(r.Context()) userID, err := GetUserIDFromContext(r.Context())
if err != nil { if err != nil {
@ -615,10 +618,12 @@ func (h *ApiHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
SendJSONError(w, err, http.StatusInternalServerError, h.logger) SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return return
} }
apiTodo := mapDomainTodoToApi(todo) // Newly created todo won't have attachments yet
apiTodo := mapDomainTodoToApi(todo, []models.AttachmentInfo{})
SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger) SendJSONResponse(w, http.StatusCreated, apiTodo, h.logger)
} }
// ListTodos remains the same, doesn't include full attachment details for performance
func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) { func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params ListTodosParams) {
userID, err := GetUserIDFromContext(r.Context()) userID, err := GetUserIDFromContext(r.Context())
if err != nil { if err != nil {
@ -627,7 +632,7 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
} }
input := service.ListTodosInput{ input := service.ListTodosInput{
Limit: 20, Limit: 20, // Default limit
Offset: 0, Offset: 0,
} }
if params.Limit != nil { if params.Limit != nil {
@ -641,13 +646,8 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
input.Status = &domainStatus input.Status = &domainStatus
} }
if params.TagId != nil { if params.TagId != nil {
input.TagID = params.TagId domainTagID := uuid.UUID(*params.TagId)
} input.TagID = &domainTagID
if params.DeadlineBefore != nil {
input.DeadlineBefore = params.DeadlineBefore
}
if params.DeadlineAfter != nil {
input.DeadlineAfter = params.DeadlineAfter
} }
todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input) todos, err := h.services.Todo.ListUserTodos(r.Context(), userID, input)
@ -658,28 +658,52 @@ func (h *ApiHandler) ListTodos(w http.ResponseWriter, r *http.Request, params Li
apiTodos := make([]models.Todo, len(todos)) apiTodos := make([]models.Todo, len(todos))
for i, todo := range todos { for i, todo := range todos {
apiTodos[i] = *mapDomainTodoToApi(&todo) // For list view, if there is an attachmentUrl, include it as a single-item array
var attachmentInfos []models.AttachmentInfo
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
attachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
} else {
attachmentInfos = []models.AttachmentInfo{}
}
mappedTodo := mapDomainTodoToApi(&todo, attachmentInfos)
if mappedTodo != nil {
apiTodos[i] = *mappedTodo
}
} }
SendJSONResponse(w, http.StatusOK, apiTodos, h.logger) SendJSONResponse(w, http.StatusOK, apiTodos, h.logger)
} }
// GetTodoById updated for single attachmentUrl
func (h *ApiHandler) GetTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { func (h *ApiHandler) GetTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
userID, err := GetUserIDFromContext(r.Context()) ctx := r.Context()
userID, err := GetUserIDFromContext(ctx)
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
domainTodoID := uuid.UUID(todoId)
todo, err := h.services.Todo.GetTodoByID(ctx, domainTodoID, userID)
if err != nil { if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger) SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return return
} }
todo, err := h.services.Todo.GetTodoByID(r.Context(), todoId, userID) // Map attachmentUrl to API model as a single-item array if present
if err != nil { var apiAttachmentInfos []models.AttachmentInfo
SendJSONError(w, err, http.StatusInternalServerError, h.logger) if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
return apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
} else {
apiAttachmentInfos = []models.AttachmentInfo{}
} }
SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger) apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
} }
// UpdateTodoById remains the same=
func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
userID, err := GetUserIDFromContext(r.Context()) userID, err := GetUserIDFromContext(r.Context())
if err != nil { if err != nil {
@ -711,9 +735,7 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo
} }
input.TagIDs = &domainTagIDs input.TagIDs = &domainTagIDs
} }
if body.Attachments != nil { // Note: Attachments are NOT updated via this endpoint in this design
input.Attachments = body.Attachments
}
todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input) todo, err := h.services.Todo.UpdateTodo(r.Context(), domainTodoID, userID, input)
if err != nil { if err != nil {
@ -721,17 +743,28 @@ func (h *ApiHandler) UpdateTodoById(w http.ResponseWriter, r *http.Request, todo
return return
} }
SendJSONResponse(w, http.StatusOK, mapDomainTodoToApi(todo), h.logger) // Prepare attachment info for API response using AttachmentUrl field
var apiAttachmentInfos []models.AttachmentInfo
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
} else {
apiAttachmentInfos = []models.AttachmentInfo{}
}
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
} }
// DeleteTodoById remains the same (service layer handles attachment deletion)
func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
userID, err := GetUserIDFromContext(r.Context()) userID, err := GetUserIDFromContext(r.Context())
if err != nil { if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger) SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return return
} }
domainTodoID := uuid.UUID(todoId)
err = h.services.Todo.DeleteTodo(r.Context(), todoId, userID) err = h.services.Todo.DeleteTodo(r.Context(), domainTodoID, userID)
if err != nil { if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger) SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return return
@ -740,6 +773,72 @@ func (h *ApiHandler) DeleteTodoById(w http.ResponseWriter, r *http.Request, todo
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// --- Attachment Handlers ---
func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
ctx := r.Context()
userID, err := GetUserIDFromContext(ctx)
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
domainTodoID := uuid.UUID(todoId)
h.logger.DebugContext(ctx, "Request to delete attachment", "todoId", todoId)
err = h.services.Todo.DeleteAttachment(ctx, domainTodoID, userID)
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
h.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoId)
w.WriteHeader(http.StatusNoContent)
}
func (h *ApiHandler) UploadOrReplaceTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
userID, err := GetUserIDFromContext(r.Context())
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
domainTodoID := uuid.UUID(todoId)
// Parse multipart form (limit to 10 MB)
err = r.ParseMultipartForm(10 << 20)
if err != nil {
SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", err), http.StatusBadRequest, h.logger)
return
}
file, fileHeader, err := r.FormFile("file")
if err != nil {
SendJSONError(w, fmt.Errorf("missing or invalid file: %w", err), http.StatusBadRequest, h.logger)
return
}
defer file.Close()
fileName := fileHeader.Filename
fileSize := fileHeader.Size
todo, err := h.services.Todo.AddAttachment(r.Context(), domainTodoID, userID, fileName, fileSize, file)
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
// Prepare attachment info for API response using AttachmentUrl field
var apiAttachmentInfos []models.AttachmentInfo
if todo.AttachmentUrl != nil && *todo.AttachmentUrl != "" {
apiAttachmentInfos = []models.AttachmentInfo{{FileId: *todo.AttachmentUrl}}
} else {
apiAttachmentInfos = []models.AttachmentInfo{}
}
apiTodo := mapDomainTodoToApi(todo, apiAttachmentInfos)
SendJSONResponse(w, http.StatusOK, apiTodo, h.logger)
}
// --- Subtask Handlers --- // --- Subtask Handlers ---
func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) { func (h *ApiHandler) CreateSubtaskForTodo(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
@ -828,53 +927,3 @@ func (h *ApiHandler) DeleteSubtaskById(w http.ResponseWriter, r *http.Request, t
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// --- Attachment Handlers ---
func (h *ApiHandler) UploadTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID) {
userID, err := GetUserIDFromContext(r.Context())
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
err = r.ParseMultipartForm(10 << 20)
if err != nil {
SendJSONError(w, fmt.Errorf("failed to parse multipart form: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger)
return
}
file, handler, err := r.FormFile("file")
if err != nil {
SendJSONError(w, fmt.Errorf("error retrieving the file from form-data: %w", domain.ErrBadRequest), http.StatusBadRequest, h.logger)
return
}
defer file.Close()
fileName := handler.Filename
fileSize := handler.Size
attachmentInfo, err := h.services.Todo.AddAttachment(r.Context(), todoId, userID, fileName, fileSize, file)
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
SendJSONResponse(w, http.StatusCreated, mapDomainAttachmentInfoToApi(attachmentInfo), h.logger)
}
func (h *ApiHandler) DeleteTodoAttachment(w http.ResponseWriter, r *http.Request, todoId openapi_types.UUID, attachmentId string) {
userID, err := GetUserIDFromContext(r.Context())
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
err = h.services.Todo.DeleteAttachment(r.Context(), todoId, userID, attachmentId)
if err != nil {
SendJSONError(w, err, http.StatusInternalServerError, h.logger)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -42,6 +42,24 @@ const (
ListTodosParamsStatusPending ListTodosParamsStatus = "pending" ListTodosParamsStatusPending ListTodosParamsStatus = "pending"
) )
// AttachmentInfo Metadata about an uploaded attachment.
type AttachmentInfo struct {
// ContentType MIME type of the uploaded file.
ContentType string `json:"contentType"`
// FileId Unique storage identifier/path for the file (used for deletion).
FileId string `json:"fileId"`
// FileName Original name of the uploaded file.
FileName string `json:"fileName"`
// FileUrl URL to access the uploaded file (e.g., a signed GCS URL).
FileUrl string `json:"fileUrl"`
// Size Size of the uploaded file in bytes.
Size int64 `json:"size"`
}
// CreateSubtaskRequest Data required to create a new Subtask. // CreateSubtaskRequest Data required to create a new Subtask.
type CreateSubtaskRequest struct { type CreateSubtaskRequest struct {
Description string `json:"description"` Description string `json:"description"`
@ -82,23 +100,8 @@ type Error struct {
Message string `json:"message"` Message string `json:"message"`
} }
// FileUploadResponse Response after successfully uploading a file. // FileUploadResponse Metadata about an uploaded attachment.
type FileUploadResponse struct { type FileUploadResponse = AttachmentInfo
// ContentType MIME type of the uploaded file.
ContentType string `json:"contentType"`
// FileId Unique identifier for the uploaded file.
FileId string `json:"fileId"`
// FileName Original name of the uploaded file.
FileName string `json:"fileName"`
// FileUrl URL to access the uploaded file.
FileUrl string `json:"fileUrl"`
// Size Size of the uploaded file in bytes.
Size int64 `json:"size"`
}
// LoginRequest Data required for logging in via email/password. // LoginRequest Data required for logging in via email/password.
type LoginRequest struct { type LoginRequest struct {
@ -157,35 +160,21 @@ type Tag struct {
// Todo Represents a Todo item. // Todo Represents a Todo item.
type Todo struct { type Todo struct {
// Attachments List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints. // AttachmentUrl Publicly accessible URL of the attached image, if any.
Attachments []string `json:"attachments"` AttachmentUrl *string `json:"attachmentUrl"`
CreatedAt *time.Time `json:"createdAt,omitempty"` CreatedAt *time.Time `json:"createdAt,omitempty"`
Deadline *time.Time `json:"deadline"`
// Deadline Optional deadline for the Todo item. Description *string `json:"description"`
Deadline *time.Time `json:"deadline"` Id *openapi_types.UUID `json:"id,omitempty"`
Status TodoStatus `json:"status"`
// Description Optional detailed description of the Todo. Subtasks *[]Subtask `json:"subtasks,omitempty"`
Description *string `json:"description"` TagIds []openapi_types.UUID `json:"tagIds"`
Id *openapi_types.UUID `json:"id,omitempty"` Title string `json:"title"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
// Status Current status of the Todo item. UserId *openapi_types.UUID `json:"userId,omitempty"`
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"`
} }
// TodoStatus Current status of the Todo item. // TodoStatus defines model for Todo.Status.
type TodoStatus string type TodoStatus string
// UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional. // UpdateSubtaskRequest Data for updating an existing Subtask. Both fields are optional.
@ -206,17 +195,13 @@ type UpdateTagRequest struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
} }
// UpdateTodoRequest Data for updating an existing Todo item. All fields are optional for partial updates. // UpdateTodoRequest Data for updating an existing Todo item. Attachment is managed via dedicated endpoints.
type UpdateTodoRequest struct { type UpdateTodoRequest struct {
// Attachments Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files.
Attachments *[]string `json:"attachments,omitempty"`
Deadline *time.Time `json:"deadline"` Deadline *time.Time `json:"deadline"`
Description *string `json:"description"` Description *string `json:"description"`
Status *UpdateTodoRequestStatus `json:"status,omitempty"` Status *UpdateTodoRequestStatus `json:"status,omitempty"`
TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"`
// TagIds Replace the existing list of associated Tag IDs. IDs must belong to the user. Title *string `json:"title,omitempty"`
TagIds *[]openapi_types.UUID `json:"tagIds,omitempty"`
Title *string `json:"title,omitempty"`
} }
// UpdateTodoRequestStatus defines model for UpdateTodoRequest.Status. // UpdateTodoRequestStatus defines model for UpdateTodoRequest.Status.
@ -259,30 +244,17 @@ type Unauthorized = Error
// ListTodosParams defines parameters for ListTodos. // ListTodosParams defines parameters for ListTodos.
type ListTodosParams struct { type ListTodosParams struct {
// Status Filter Todos by status.
Status *ListTodosParamsStatus `form:"status,omitempty" json:"status,omitempty"` Status *ListTodosParamsStatus `form:"status,omitempty" json:"status,omitempty"`
TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"`
// TagId Filter Todos by a specific Tag ID. Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
TagId *openapi_types.UUID `form:"tagId,omitempty" json:"tagId,omitempty"` Offset *int `form:"offset,omitempty" json:"offset,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"`
} }
// ListTodosParamsStatus defines parameters for ListTodos. // ListTodosParamsStatus defines parameters for ListTodos.
type ListTodosParamsStatus string type ListTodosParamsStatus string
// UploadTodoAttachmentMultipartBody defines parameters for UploadTodoAttachment. // UploadOrReplaceTodoAttachmentMultipartBody defines parameters for UploadOrReplaceTodoAttachment.
type UploadTodoAttachmentMultipartBody struct { type UploadOrReplaceTodoAttachmentMultipartBody struct {
File openapi_types.File `json:"file"` File openapi_types.File `json:"file"`
} }
@ -304,8 +276,8 @@ type CreateTodoJSONRequestBody = CreateTodoRequest
// UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType. // UpdateTodoByIdJSONRequestBody defines body for UpdateTodoById for application/json ContentType.
type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest type UpdateTodoByIdJSONRequestBody = UpdateTodoRequest
// UploadTodoAttachmentMultipartRequestBody defines body for UploadTodoAttachment for multipart/form-data ContentType. // UploadOrReplaceTodoAttachmentMultipartRequestBody defines body for UploadOrReplaceTodoAttachment for multipart/form-data ContentType.
type UploadTodoAttachmentMultipartRequestBody UploadTodoAttachmentMultipartBody type UploadOrReplaceTodoAttachmentMultipartRequestBody UploadOrReplaceTodoAttachmentMultipartBody
// CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType. // CreateSubtaskForTodoJSONRequestBody defines body for CreateSubtaskForTodo for application/json ContentType.
type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest type CreateSubtaskForTodoJSONRequestBody = CreateSubtaskRequest

View File

@ -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"`
}

View File

@ -16,20 +16,30 @@ const (
) )
type Todo struct { type Todo struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"userId"` UserID uuid.UUID `json:"userId"`
Title string `json:"title"` Title string `json:"title"`
Description *string `json:"description"` // Nullable Description *string `json:"description"` // Nullable
Status TodoStatus `json:"status"` Status TodoStatus `json:"status"`
Deadline *time.Time `json:"deadline"` // Nullable Deadline *time.Time `json:"deadline"` // Nullable
TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching TagIDs []uuid.UUID `json:"tagIds"` // Populated after fetching
Tags []Tag `json:"-"` // Can hold full tag objects if needed, loaded separately Tags []Tag `json:"-"` // Loaded separately
Attachments []string `json:"attachments"` // Stores identifiers (e.g., file IDs or URLs) AttachmentUrl *string `json:"attachmentUrl"` // Renamed and changed type
Subtasks []Subtask `json:"subtasks"` // Populated after fetching Subtasks []Subtask `json:"subtasks"` // Populated after fetching
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// Keep AttachmentInfo for upload responses
type AttachmentInfo struct {
FileID string `json:"fileId"`
FileName string `json:"fileName"`
FileURL string `json:"fileUrl"`
ContentType string `json:"contentType"`
Size int64 `json:"size"`
}
// Helper functions remain the same
func NullStringToStringPtr(ns sql.NullString) *string { func NullStringToStringPtr(ns sql.NullString) *string {
if ns.Valid { if ns.Valid {
return &ns.String return &ns.String

View File

@ -40,7 +40,7 @@ type ListTodosParams struct {
TagID *uuid.UUID TagID *uuid.UUID
DeadlineBefore *time.Time DeadlineBefore *time.Time
DeadlineAfter *time.Time DeadlineAfter *time.Time
ListParams // Embed pagination ListParams
} }
type TodoRepository interface { type TodoRepository interface {
@ -54,10 +54,8 @@ type TodoRepository interface {
RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error RemoveTag(ctx context.Context, todoID, tagID uuid.UUID) error
SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error SetTags(ctx context.Context, todoID uuid.UUID, tagIDs []uuid.UUID) error
GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error) GetTags(ctx context.Context, todoID uuid.UUID) ([]domain.Tag, error)
// Attachment associations (using simple string array) // Attachment URL management
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error UpdateAttachmentURL(ctx context.Context, todoID, userID uuid.UUID, attachmentURL *string) error
RemoveAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error
SetAttachments(ctx context.Context, todoID, userID uuid.UUID, attachmentIDs []string) error
} }
type SubtaskRepository interface { type SubtaskRepository interface {

View File

@ -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;

View File

@ -1,6 +1,6 @@
-- name: CreateTodo :one -- name: CreateTodo :one
INSERT INTO todos (user_id, title, description, status, deadline) INSERT INTO todos (user_id, title, description, status, deadline, attachment_url)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *; RETURNING *;
-- name: GetTodoByID :one -- name: GetTodoByID :one
@ -11,13 +11,13 @@ WHERE id = $1 AND user_id = $2 LIMIT 1;
SELECT t.* FROM todos t SELECT t.* FROM todos t
LEFT JOIN todo_tags tt ON t.id = tt.todo_id LEFT JOIN todo_tags tt ON t.id = tt.todo_id
WHERE WHERE
t.user_id = sqlc.arg('user_id') -- Use sqlc.arg for required params t.user_id = sqlc.arg('user_id')
AND (sqlc.narg('status_filter')::todo_status IS NULL OR t.status = sqlc.narg('status_filter')) AND (sqlc.narg('status_filter')::todo_status IS NULL OR t.status = sqlc.narg('status_filter'))
AND (sqlc.narg('tag_id_filter')::uuid IS NULL OR tt.tag_id = sqlc.narg('tag_id_filter')) AND (sqlc.narg('tag_id_filter')::uuid IS NULL OR tt.tag_id = sqlc.narg('tag_id_filter'))
AND (sqlc.narg('deadline_before_filter')::timestamptz IS NULL OR t.deadline < sqlc.narg('deadline_before_filter')) AND (sqlc.narg('deadline_before_filter')::timestamptz IS NULL OR t.deadline < sqlc.narg('deadline_before_filter'))
AND (sqlc.narg('deadline_after_filter')::timestamptz IS NULL OR t.deadline > sqlc.narg('deadline_after_filter')) AND (sqlc.narg('deadline_after_filter')::timestamptz IS NULL OR t.deadline > sqlc.narg('deadline_after_filter'))
GROUP BY t.id -- Still needed due to LEFT JOIN potentially multiplying rows if a todo has multiple tags GROUP BY t.id
ORDER BY t.created_at DESC -- Or your desired order ORDER BY t.created_at DESC
LIMIT sqlc.arg('limit') LIMIT sqlc.arg('limit')
OFFSET sqlc.arg('offset'); OFFSET sqlc.arg('offset');
@ -25,10 +25,10 @@ OFFSET sqlc.arg('offset');
UPDATE todos UPDATE todos
SET SET
title = COALESCE(sqlc.narg(title), title), title = COALESCE(sqlc.narg(title), title),
description = sqlc.narg(description), -- Allow setting description to NULL description = sqlc.narg(description),
status = COALESCE(sqlc.narg(status), status), status = COALESCE(sqlc.narg(status), status),
deadline = sqlc.narg(deadline), -- Allow setting deadline to NULL deadline = sqlc.narg(deadline),
attachments = COALESCE(sqlc.narg(attachments), attachments) attachment_url = COALESCE(sqlc.narg(attachment_url), attachment_url) -- Update attachment_url
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
RETURNING *; RETURNING *;
@ -36,12 +36,8 @@ RETURNING *;
DELETE FROM todos DELETE FROM todos
WHERE id = $1 AND user_id = $2; WHERE id = $1 AND user_id = $2;
-- name: AddAttachmentToTodo :exec -- name: UpdateTodoAttachmentURL :exec
-- Sets or clears the attachment URL for a specific todo
UPDATE todos UPDATE todos
SET attachments = array_append(attachments, $1) SET attachment_url = $1 -- $1 will be the URL (TEXT) or NULL
WHERE id = $2 AND user_id = $3;
-- name: RemoveAttachmentFromTodo :exec
UPDATE todos
SET attachments = array_remove(attachments, $1)
WHERE id = $2 AND user_id = $3; WHERE id = $2 AND user_id = $3;

View File

@ -29,15 +29,15 @@ func NewPgxTodoRepository(queries *db.Queries, pool *pgxpool.Pool) TodoRepositor
func mapDbTodoToDomain(dbTodo db.Todo) *domain.Todo { func mapDbTodoToDomain(dbTodo db.Todo) *domain.Todo {
return &domain.Todo{ return &domain.Todo{
ID: dbTodo.ID, ID: dbTodo.ID,
UserID: dbTodo.UserID, UserID: dbTodo.UserID,
Title: dbTodo.Title, Title: dbTodo.Title,
Description: domain.NullStringToStringPtr(dbTodo.Description), Description: domain.NullStringToStringPtr(dbTodo.Description),
Status: domain.TodoStatus(dbTodo.Status), Status: domain.TodoStatus(dbTodo.Status),
Deadline: dbTodo.Deadline, AttachmentUrl: domain.NullStringToStringPtr(dbTodo.AttachmentUrl),
Attachments: dbTodo.Attachments, Deadline: dbTodo.Deadline,
CreatedAt: dbTodo.CreatedAt, CreatedAt: dbTodo.CreatedAt,
UpdatedAt: dbTodo.UpdatedAt, UpdatedAt: dbTodo.UpdatedAt,
} }
} }
@ -161,7 +161,6 @@ func (r *pgxTodoRepository) Update(
Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil}, Description: sql.NullString{String: derefString(updateData.Description), Valid: updateData.Description != nil},
Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true}, Status: db.NullTodoStatus{TodoStatus: db.TodoStatus(updateData.Status), Valid: true},
Deadline: updateData.Deadline, Deadline: updateData.Deadline,
Attachments: updateData.Attachments,
} }
dbTodo, err := r.q.UpdateTodo(ctx, params) dbTodo, err := r.q.UpdateTodo(ctx, params)
@ -251,61 +250,19 @@ func (r *pgxTodoRepository) GetTags(
return tags, nil return tags, nil
} }
// --- Attachments (String Identifiers in Array) --- func (r *pgxTodoRepository) UpdateAttachmentURL(
func (r *pgxTodoRepository) AddAttachment(
ctx context.Context, ctx context.Context,
todoID, userID uuid.UUID, todoID, userID uuid.UUID,
attachmentID string, attachmentURL *string,
) error { ) error {
if _, err := r.GetByID(ctx, todoID, userID); err != nil { query := `
return err UPDATE todos
} SET attachment_url = $1
if err := r.q.AddAttachmentToTodo(ctx, db.AddAttachmentToTodoParams{ WHERE id = $2 AND user_id = $3
ArrayAppend: attachmentID, `
ID: todoID, _, err := r.pool.Exec(ctx, query, attachmentURL, todoID, userID)
UserID: userID,
}); err != nil {
return fmt.Errorf("failed to add attachment: %w", err)
}
return nil
}
func (r *pgxTodoRepository) RemoveAttachment(
ctx context.Context,
todoID, userID uuid.UUID,
attachmentID string,
) error {
if _, err := r.GetByID(ctx, todoID, userID); err != nil {
return err
}
if err := r.q.RemoveAttachmentFromTodo(ctx, db.RemoveAttachmentFromTodoParams{
ArrayRemove: attachmentID,
ID: todoID,
UserID: userID,
}); err != nil {
return fmt.Errorf("failed to remove attachment: %w", err)
}
return nil
}
func (r *pgxTodoRepository) SetAttachments(
ctx context.Context,
todoID, userID uuid.UUID,
attachmentIDs []string,
) error {
_, err := r.GetByID(ctx, todoID, userID)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to update attachment URL: %w", err)
}
updateParams := db.UpdateTodoParams{
ID: todoID,
UserID: userID,
Attachments: attachmentIDs,
}
_, err = r.q.UpdateTodo(ctx, updateParams)
if err != nil {
return fmt.Errorf("failed to set attachments using UpdateTodo: %w", err)
} }
return nil return nil
} }

View File

@ -2,12 +2,14 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"mime" "mime"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"cloud.google.com/go/storage" "cloud.google.com/go/storage"
"github.com/Sosokker/todolist-backend/internal/config" "github.com/Sosokker/todolist-backend/internal/config"
@ -16,79 +18,153 @@ import (
) )
type gcsStorageService struct { type gcsStorageService struct {
bucket string bucket string
client *storage.Client client *storage.Client
logger *slog.Logger logger *slog.Logger
baseDir string baseDir string
signedURLExpiry time.Duration
} }
func NewGCStorageService(cfg config.GCSStorageConfig, logger *slog.Logger) (FileStorageService, error) { func NewGCStorageService(cfg config.GCSStorageConfig, logger *slog.Logger) (FileStorageService, error) {
if cfg.BucketName == "" {
return nil, fmt.Errorf("GCS bucket name is required")
}
opts := []option.ClientOption{} opts := []option.ClientOption{}
// Prefer environment variable GOOGLE_APPLICATION_CREDENTIALS
// Only use CredentialsFile from config if it's explicitly set
if cfg.CredentialsFile != "" { if cfg.CredentialsFile != "" {
opts = append(opts, option.WithCredentialsFile(cfg.CredentialsFile)) opts = append(opts, option.WithCredentialsFile(cfg.CredentialsFile))
logger.Info("Using GCS credentials file specified in config", "path", cfg.CredentialsFile)
} else {
logger.Info("Using default GCS credentials (e.g., GOOGLE_APPLICATION_CREDENTIALS or Application Default Credentials)")
} }
client, err := storage.NewClient(context.Background(), opts...)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := storage.NewClient(ctx, opts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create GCS client: %w", err) return nil, fmt.Errorf("failed to create GCS client: %w", err)
} }
// Check bucket existence and permissions
_, err = client.Bucket(cfg.BucketName).Attrs(ctx)
if err != nil {
client.Close()
return nil, fmt.Errorf("failed to access GCS bucket '%s': %w", cfg.BucketName, err)
}
logger.Info("GCS storage service initialized", "bucket", cfg.BucketName, "baseDir", cfg.BaseDir)
return &gcsStorageService{ return &gcsStorageService{
bucket: cfg.BucketName, bucket: cfg.BucketName,
client: client, client: client,
logger: logger.With("service", "gcsstorage"), logger: logger.With("service", "gcsstorage"),
baseDir: cfg.BaseDir, baseDir: strings.Trim(cfg.BaseDir, "/"), // Ensure no leading/trailing slashes
signedURLExpiry: 168 * time.Hour, // Default signed URL validity
}, nil }, nil
} }
func (s *gcsStorageService) GenerateUniqueObjectName(originalFilename string) string { // GenerateUniqueObjectName creates a unique object path within the bucket's base directory.
// Example: attachments/<user_uuid>/<todo_uuid>/<file_uuid>.<ext>
func (s *gcsStorageService) GenerateUniqueObjectName(userID, todoID uuid.UUID, originalFilename string) string {
ext := filepath.Ext(originalFilename) ext := filepath.Ext(originalFilename)
return uuid.NewString() + ext fileName := uuid.NewString() + ext
objectPath := filepath.Join(s.baseDir, userID.String(), todoID.String(), fileName)
return filepath.ToSlash(objectPath)
} }
func (s *gcsStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) { func (s *gcsStorageService) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (string, string, error) {
objectName := filepath.Join(s.baseDir, userID.String(), todoID.String(), s.GenerateUniqueObjectName(originalFilename)) objectName := s.GenerateUniqueObjectName(userID, todoID, originalFilename)
wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctx)
wc.ContentType = mime.TypeByExtension(filepath.Ext(originalFilename)) ctxUpload, cancel := context.WithTimeout(ctx, 5*time.Minute) // Timeout for upload
wc.ChunkSize = 0 defer cancel()
wc := s.client.Bucket(s.bucket).Object(objectName).NewWriter(ctxUpload)
// Attempt to determine Content-Type
contentType := mime.TypeByExtension(filepath.Ext(originalFilename))
if contentType == "" {
contentType = "application/octet-stream" // Default fallback
// Could potentially read first 512 bytes from reader here if it's TeeReader, but might be complex
}
wc.ContentType = contentType
wc.ChunkSize = 0 // Recommended for better performance unless files are huge
s.logger.DebugContext(ctx, "Uploading file to GCS", "bucket", s.bucket, "object", objectName, "contentType", contentType, "size", size)
written, err := io.Copy(wc, reader) written, err := io.Copy(wc, reader)
if err != nil { if err != nil {
wc.Close() // Close writer explicitly on error to clean up potential partial uploads
s.logger.ErrorContext(ctx, "Failed to upload to GCS", "error", err, "object", objectName) _ = wc.CloseWithError(fmt.Errorf("copy failed: %w", err))
s.logger.ErrorContext(ctx, "Failed to copy data to GCS", "error", err, "object", objectName)
return "", "", fmt.Errorf("failed to upload to GCS: %w", err) return "", "", fmt.Errorf("failed to upload to GCS: %w", err)
} }
if written != size {
wc.Close() // Close the writer to finalize the upload
s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName)
return "", "", fmt.Errorf("file size mismatch during upload")
}
if err := wc.Close(); err != nil { if err := wc.Close(); err != nil {
s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName) s.logger.ErrorContext(ctx, "Failed to finalize GCS upload", "error", err, "object", objectName)
return "", "", fmt.Errorf("failed to finalize upload: %w", err) return "", "", fmt.Errorf("failed to finalize upload: %w", err)
} }
contentType := wc.ContentType
if contentType == "" { if written != size {
contentType = "application/octet-stream" s.logger.WarnContext(ctx, "File size mismatch during GCS upload", "expected", size, "written", written, "object", objectName)
// Optionally delete the potentially corrupted file
_ = s.Delete(context.Background(), objectName) // Use background context for cleanup
return "", "", fmt.Errorf("file size mismatch during upload")
} }
s.logger.InfoContext(ctx, "File uploaded successfully to GCS", "object", objectName, "size", written, "contentType", contentType)
// Return the object name (path) as the storage ID
return objectName, contentType, nil return objectName, contentType, nil
} }
func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error { func (s *gcsStorageService) Delete(ctx context.Context, storageID string) error {
objectName := filepath.Clean(storageID) objectName := filepath.Clean(storageID) // storageID is the object path
if strings.Contains(objectName, "..") { if strings.Contains(objectName, "..") || !strings.HasPrefix(objectName, s.baseDir+"/") && s.baseDir != "" {
s.logger.WarnContext(ctx, "Attempted directory traversal in GCS delete", "storageId", storageID) s.logger.WarnContext(ctx, "Attempted invalid delete operation", "storageId", storageID, "baseDir", s.baseDir)
return fmt.Errorf("invalid storage ID") return fmt.Errorf("invalid storage ID for deletion")
} }
ctxDelete, cancel := context.WithTimeout(ctx, 30*time.Second) // Timeout for delete
defer cancel()
o := s.client.Bucket(s.bucket).Object(objectName) o := s.client.Bucket(s.bucket).Object(objectName)
err := o.Delete(ctx) err := o.Delete(ctxDelete)
if err != nil && err != storage.ErrObjectNotExist {
if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
s.logger.WarnContext(ctx, "Attempted to delete non-existent GCS object", "storageId", storageID)
return nil // Treat as success if already deleted
}
s.logger.ErrorContext(ctx, "Failed to delete GCS object", "error", err, "storageId", storageID) s.logger.ErrorContext(ctx, "Failed to delete GCS object", "error", err, "storageId", storageID)
return fmt.Errorf("could not delete GCS object: %w", err) return fmt.Errorf("could not delete GCS object: %w", err)
} }
s.logger.InfoContext(ctx, "GCS object deleted", "storageId", storageID)
s.logger.InfoContext(ctx, "GCS object deleted successfully", "storageId", storageID)
return nil return nil
} }
// GetURL generates a signed URL for accessing the private GCS object.
func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) { func (s *gcsStorageService) GetURL(ctx context.Context, storageID string) (string, error) {
objectName := filepath.Clean(storageID) objectName := storageID
url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", s.bucket, objectName) if strings.Contains(objectName, "..") || (s.baseDir != "" && !strings.HasPrefix(objectName, s.baseDir+"/")) {
s.logger.WarnContext(ctx, "Attempted invalid GetURL operation", "storageId", storageID, "baseDir", s.baseDir)
return "", fmt.Errorf("invalid storage ID for URL generation")
}
opts := &storage.SignedURLOptions{
Scheme: storage.SigningSchemeV4,
Method: "GET",
Expires: time.Now().Add(s.signedURLExpiry),
}
url, err := s.client.Bucket(s.bucket).SignedURL(objectName, opts)
if err != nil {
s.logger.ErrorContext(ctx, "Failed to generate signed URL", "error", err, "object", objectName)
return "", fmt.Errorf("could not get signed URL for object: %w", err)
}
s.logger.DebugContext(ctx, "Generated signed URL", "object", objectName, "expiry", opts.Expires)
return url, nil return url, nil
} }

View File

@ -78,7 +78,7 @@ type UpdateTodoInput struct {
Status *domain.TodoStatus Status *domain.TodoStatus
Deadline *time.Time Deadline *time.Time
TagIDs *[]uuid.UUID TagIDs *[]uuid.UUID
Attachments *[]string // Attachments are managed via separate endpoints
} }
type ListTodosInput struct { type ListTodosInput struct {
@ -92,18 +92,19 @@ type ListTodosInput struct {
type TodoService interface { type TodoService interface {
CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error) CreateTodo(ctx context.Context, userID uuid.UUID, input CreateTodoInput) (*domain.Todo, error)
GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Includes tags, subtasks GetTodoByID(ctx context.Context, todoID, userID uuid.UUID) (*domain.Todo, error) // Fetches attachment URL
ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error) // Includes tags ListUserTodos(ctx context.Context, userID uuid.UUID, input ListTodosInput) ([]domain.Todo, error)
UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID, input UpdateTodoInput) (*domain.Todo, error)
DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error
// Subtask methods delegate to SubtaskService but check Todo ownership first // Subtask methods
ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error) ListSubtasks(ctx context.Context, todoID, userID uuid.UUID) ([]domain.Subtask, error)
CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error) CreateSubtask(ctx context.Context, todoID, userID uuid.UUID, input CreateSubtaskInput) (*domain.Subtask, error)
UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error)
DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error
// Attachment methods // Attachment methods
AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) // Returns info like ID/URL AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.Todo, error)
DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, attachmentID string) error // Uploads, gets URL, updates Todo, returns updated Todo
DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID) error // Deletes from storage and clears Todo URL
} }
// --- Subtask Service --- // --- Subtask Service ---
@ -131,9 +132,10 @@ type FileStorageService interface {
Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (storageID string, contentType string, err error) Upload(ctx context.Context, userID, todoID uuid.UUID, originalFilename string, reader io.Reader, size int64) (storageID string, contentType string, err error)
// Delete removes the file associated with the given storage identifier. // Delete removes the file associated with the given storage identifier.
Delete(ctx context.Context, storageID string) error Delete(ctx context.Context, storageID string) error
// GetURL retrieves a publicly accessible URL for the storage ID (optional, might not be needed if files are served differently). // GetURL retrieves a publicly accessible URL for the storage ID (e.g., signed URL for GCS).
GetURL(ctx context.Context, storageID string) (string, error) GetURL(ctx context.Context, storageID string) (string, error)
GenerateUniqueObjectName(originalFilename string) string // GenerateUniqueObjectName creates a unique storage path/name for a file.
GenerateUniqueObjectName(userID, todoID uuid.UUID, originalFilename string) string
} }
// ServiceRegistry bundles services // ServiceRegistry bundles services

View File

@ -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
}

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"time"
"github.com/Sosokker/todolist-backend/internal/domain" "github.com/Sosokker/todolist-backend/internal/domain"
"github.com/Sosokker/todolist-backend/internal/repository" "github.com/Sosokker/todolist-backend/internal/repository"
@ -14,8 +15,8 @@ import (
type todoService struct { type todoService struct {
todoRepo repository.TodoRepository todoRepo repository.TodoRepository
tagService TagService // Depend on TagService for validation tagService TagService
subtaskService SubtaskService // Depend on SubtaskService subtaskService SubtaskService
storageService FileStorageService storageService FileStorageService
logger *slog.Logger logger *slog.Logger
} }
@ -56,13 +57,13 @@ func (s *todoService) CreateTodo(ctx context.Context, userID uuid.UUID, input Cr
} }
newTodo := &domain.Todo{ newTodo := &domain.Todo{
UserID: userID, UserID: userID,
Title: input.Title, Title: input.Title,
Description: input.Description, Description: input.Description,
Status: status, Status: status,
Deadline: input.Deadline, Deadline: input.Deadline,
TagIDs: input.TagIDs, TagIDs: input.TagIDs,
Attachments: []string{}, AttachmentUrl: nil, // No attachment on creation
} }
createdTodo, err := s.todoRepo.Create(ctx, newTodo) createdTodo, err := s.todoRepo.Create(ctx, newTodo)
@ -114,6 +115,9 @@ func (s *todoService) GetTodoByID(ctx context.Context, todoID, userID uuid.UUID)
todo.Subtasks = subtasks todo.Subtasks = subtasks
} }
// Note: todo.Attachments currently holds storage IDs (paths).
// The handler will call GetAttachmentURLs to convert these to full URLs for the API response.
return todo, nil return todo, nil
} }
@ -160,20 +164,21 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID,
} }
updateData := &domain.Todo{ updateData := &domain.Todo{
ID: existingTodo.ID, ID: existingTodo.ID,
UserID: existingTodo.UserID, UserID: existingTodo.UserID,
Title: existingTodo.Title, Title: existingTodo.Title,
Description: existingTodo.Description, Description: existingTodo.Description,
Status: existingTodo.Status, Status: existingTodo.Status,
Deadline: existingTodo.Deadline, Deadline: existingTodo.Deadline,
Attachments: existingTodo.Attachments, TagIDs: existingTodo.TagIDs,
AttachmentUrl: existingTodo.AttachmentUrl, // Single attachment URL
} }
updated := false updated := false
if input.Title != nil { if input.Title != nil {
if *input.Title == "" { if err := ValidateTodoTitle(*input.Title); err != nil {
return nil, fmt.Errorf("title cannot be empty: %w", domain.ErrValidation) return nil, err
} }
updateData.Title = *input.Title updateData.Title = *input.Title
updated = true updated = true
@ -203,21 +208,10 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID,
s.logger.ErrorContext(ctx, "Failed to update tags for todo", "error", err, "todoId", todoID) s.logger.ErrorContext(ctx, "Failed to update tags for todo", "error", err, "todoId", todoID)
return nil, domain.ErrInternalServer return nil, domain.ErrInternalServer
} }
updateData.TagIDs = *input.TagIDs
tagsUpdated = true tagsUpdated = true
} }
attachmentsUpdated := false // Update the core fields if anything changed
if input.Attachments != nil {
err = s.todoRepo.SetAttachments(ctx, todoID, userID, *input.Attachments)
if err != nil {
s.logger.ErrorContext(ctx, "Failed to update attachments list for todo", "error", err, "todoId", todoID)
return nil, domain.ErrInternalServer
}
updateData.Attachments = *input.Attachments
attachmentsUpdated = true
}
var updatedRepoTodo *domain.Todo var updatedRepoTodo *domain.Todo
if updated { if updated {
updatedRepoTodo, err = s.todoRepo.Update(ctx, todoID, userID, updateData) updatedRepoTodo, err = s.todoRepo.Update(ctx, todoID, userID, updateData)
@ -226,42 +220,57 @@ func (s *todoService) UpdateTodo(ctx context.Context, todoID, userID uuid.UUID,
return nil, domain.ErrInternalServer return nil, domain.ErrInternalServer
} }
} else { } else {
updatedRepoTodo = updateData // If only tags were updated, we still need the latest full todo data
updatedRepoTodo = existingTodo
} }
if !updated && (tagsUpdated || attachmentsUpdated) { // If tags were updated, reload the full todo to get the updated TagIDs array
updatedRepoTodo.Title = existingTodo.Title if tagsUpdated {
updatedRepoTodo.Description = existingTodo.Description reloadedTodo, reloadErr := s.GetTodoByID(ctx, todoID, userID)
if reloadErr != nil {
s.logger.WarnContext(ctx, "Failed to reload todo after tag update, returning potentially stale data", "error", reloadErr, "todoId", todoID)
// 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) return updatedRepoTodo, nil // Return the result from repo Update or existing if only tags changed
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
} }
func (s *todoService) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error { func (s *todoService) DeleteTodo(ctx context.Context, todoID, userID uuid.UUID) error {
existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID) existingTodo, err := s.todoRepo.GetByID(ctx, todoID, userID)
if err != nil { if err != nil {
return err if errors.Is(err, domain.ErrNotFound) {
return nil // Already deleted or doesn't exist/belong to user
}
return err // Internal error
} }
attachmentIDsToDelete := existingTodo.Attachments // Delete the Todo record from the database first
err = s.todoRepo.Delete(ctx, todoID, userID) err = s.todoRepo.Delete(ctx, todoID, userID)
if err != nil { if err != nil {
s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID) s.logger.ErrorContext(ctx, "Failed to delete todo from repo", "error", err, "todoId", todoID, "userId", userID)
return domain.ErrInternalServer return domain.ErrInternalServer
} }
for _, storageID := range attachmentIDsToDelete { // If there is an attachment, attempt to delete it from storage (best effort)
if err := s.storageService.Delete(ctx, storageID); err != nil { if existingTodo.AttachmentUrl != nil {
storageID := *existingTodo.AttachmentUrl
deleteCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
if err := s.storageService.Delete(deleteCtx, storageID); err != nil {
s.logger.WarnContext(ctx, "Failed to delete attachment file during todo deletion", "error", err, "storageId", storageID, "todoId", todoID) s.logger.WarnContext(ctx, "Failed to delete attachment file during todo deletion", "error", err, "storageId", storageID, "todoId", todoID)
} else {
s.logger.InfoContext(ctx, "Deleted attachment file during todo deletion", "storageId", storageID, "todoId", todoID)
} }
} }
s.logger.InfoContext(ctx, "Successfully deleted todo and attempted attachment cleanup", "todoId", todoID, "userId", userID)
return nil return nil
} }
@ -284,72 +293,67 @@ func (s *todoService) CreateSubtask(ctx context.Context, todoID, userID uuid.UUI
} }
func (s *todoService) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) { func (s *todoService) UpdateSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID, input UpdateSubtaskInput) (*domain.Subtask, error) {
// Check if parent todo belongs to user first (optional but safer)
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
if err != nil {
return nil, err
}
// Subtask service's GetByID/Update methods inherently check ownership via JOINs
return s.subtaskService.Update(ctx, subtaskID, userID, input) return s.subtaskService.Update(ctx, subtaskID, userID, input)
} }
func (s *todoService) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error { func (s *todoService) DeleteSubtask(ctx context.Context, todoID, subtaskID, userID uuid.UUID) error {
// Check if parent todo belongs to user first (optional but safer)
_, err := s.todoRepo.GetByID(ctx, todoID, userID)
if err != nil {
return err
}
// Subtask service's Delete method inherently checks ownership via JOINs
return s.subtaskService.Delete(ctx, subtaskID, userID) return s.subtaskService.Delete(ctx, subtaskID, userID)
} }
// --- Attachment Methods --- (Implementation depends on FileStorageService) // --- Attachment Methods (Simplified) ---
func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, originalFilename string, fileSize int64, fileContent io.Reader) (*domain.AttachmentInfo, error) { func (s *todoService) AddAttachment(ctx context.Context, todoID, userID uuid.UUID, fileName string, fileSize int64, fileContent io.Reader) (*domain.Todo, error) {
_, err := s.todoRepo.GetByID(ctx, todoID, userID) _, err := s.todoRepo.GetByID(ctx, todoID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
storageID, contentType, err := s.storageService.Upload(ctx, userID, todoID, originalFilename, fileContent, fileSize) storageID, _, err := s.storageService.Upload(ctx, userID, todoID, fileName, fileContent, fileSize)
if err != nil { if err != nil {
s.logger.ErrorContext(ctx, "Failed to upload attachment to storage", "error", err, "todoId", todoID, "fileName", originalFilename) s.logger.ErrorContext(ctx, "Failed to upload attachment", "error", err, "todoId", todoID)
return nil, domain.ErrInternalServer return nil, err
} }
if err = s.todoRepo.AddAttachment(ctx, todoID, userID, storageID); err != nil { // Construct the public URL for the uploaded file in GCS
s.logger.ErrorContext(ctx, "Failed to add attachment storage ID to todo", "error", err, "todoId", todoID, "storageId", storageID) publicURL, err := s.storageService.GetURL(ctx, storageID)
if delErr := s.storageService.Delete(context.Background(), storageID); delErr != nil { if err != nil {
s.logger.ErrorContext(ctx, "Failed to delete orphaned attachment file after DB error", "deleteError", delErr, "storageId", storageID) s.logger.ErrorContext(ctx, "Failed to generate public URL for attachment", "error", err, "todoId", todoID, "storageId", storageID)
} return nil, err
return nil, domain.ErrInternalServer
} }
fileURL, _ := s.storageService.GetURL(ctx, storageID) if err := s.todoRepo.UpdateAttachmentURL(ctx, todoID, userID, &publicURL); err != nil {
s.logger.ErrorContext(ctx, "Failed to update attachment URL in repo", "error", err, "todoId", todoID)
return nil, err
}
return &domain.AttachmentInfo{ s.logger.InfoContext(ctx, "Attachment added successfully", "todoId", todoID, "storageId", storageID)
FileID: storageID,
FileName: originalFilename, return s.GetTodoByID(ctx, todoID, userID)
FileURL: fileURL,
ContentType: contentType,
Size: fileSize,
}, nil
} }
func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID, storageID string) error { func (s *todoService) DeleteAttachment(ctx context.Context, todoID, userID uuid.UUID) error {
todo, err := s.todoRepo.GetByID(ctx, todoID, userID) _, err := s.todoRepo.GetByID(ctx, todoID, userID)
if err != nil { if err != nil {
return err return err
} }
found := false if err := s.todoRepo.UpdateAttachmentURL(ctx, todoID, userID, nil); err != nil {
for _, att := range todo.Attachments { s.logger.ErrorContext(ctx, "Failed to update attachment URL in repo", "error", err, "todoId", todoID)
if att == storageID { return err
found = true
break
}
}
if !found {
return fmt.Errorf("attachment '%s' not found on todo %s: %w", storageID, todoID, domain.ErrNotFound)
}
if err = s.todoRepo.RemoveAttachment(ctx, todoID, userID, storageID); err != nil {
s.logger.ErrorContext(ctx, "Failed to remove attachment ID from todo", "error", err, "todoId", todoID, "storageId", storageID)
return domain.ErrInternalServer
}
if err = s.storageService.Delete(ctx, storageID); err != nil {
s.logger.ErrorContext(ctx, "Failed to delete attachment file from storage after removing DB ref", "error", err, "storageId", storageID)
return nil
} }
s.logger.InfoContext(ctx, "Attachment deleted successfully", "todoId", todoID)
return nil return nil
} }

View 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;

View 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.

View File

@ -1,9 +1,9 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Todolist API title: Todolist API
version: 1.2.0 # Incremented version version: 1.3.0 # Incremented version
description: | description: |
API for managing Todo items, including CRUD operations, subtasks, deadlines, attachments, and user-defined Tags. API for managing Todo items, including CRUD operations, subtasks, deadlines, attachments (stored in GCS), and user-defined Tags.
Supports user authentication via email/password (JWT) and Google OAuth. Supports user authentication via email/password (JWT) and Google OAuth.
Designed for use with oapi-codegen and Chi. Designed for use with oapi-codegen and Chi.
@ -11,26 +11,22 @@ info:
**Note on Tag Deletion:** Deleting a Tag will typically remove its association from any Todo items currently using it. **Note on Tag Deletion:** Deleting a Tag will typically remove its association from any Todo items currently using it.
servers: servers:
# The base path for all API routes defined below.
# oapi-codegen will use this when setting up routes with HandlerFromMux.
- url: /api/v1 - url: /api/v1
description: API version 1 description: API version 1
components: components:
# Security Schemes used by the API
securitySchemes: securitySchemes:
BearerAuth: # Used by API clients (non-browser) BearerAuth:
type: http type: http
scheme: bearer scheme: bearer
bearerFormat: JWT bearerFormat: JWT
description: JWT authentication token provided in the Authorization header. description: JWT authentication token provided in the Authorization header.
CookieAuth: # Used by the web application (browser) CookieAuth:
type: apiKey type: apiKey
in: cookie in: cookie
name: jwt_token # Name needs to match config.AppConfig.CookieName name: jwt_token
description: JWT authentication token provided via an HTTP-only cookie. description: JWT authentication token provided via an HTTP-only cookie.
# Reusable Schemas
schemas: schemas:
# --- User Schemas --- # --- User Schemas ---
User: User:
@ -80,7 +76,7 @@ components:
password: password:
type: string type: string
minLength: 6 minLength: 6
writeOnly: true # Password should not appear in responses writeOnly: true
required: required:
- username - username
- email - email
@ -123,9 +119,6 @@ components:
type: string type: string
minLength: 3 minLength: 3
maxLength: 50 maxLength: 50
# Add other updatable fields like email if needed (consider verification flow)
# Password updates might warrant a separate endpoint /users/me/password
# No required fields, allows partial updates
# --- Tag Schemas --- # --- Tag Schemas ---
Tag: Tag:
@ -146,7 +139,7 @@ components:
description: Name of the tag (e.g., "Work", "Personal"). Must be unique per user. description: Name of the tag (e.g., "Work", "Personal"). Must be unique per user.
color: color:
type: string type: string
format: hexcolor # Custom format hint, e.g., #FF5733 format: hexcolor
nullable: true nullable: true
description: Optional color associated with the tag. description: Optional color associated with the tag.
icon: icon:
@ -210,71 +203,68 @@ components:
maxLength: 30 maxLength: 30
description: New icon identifier. description: New icon identifier.
# --- Attachment Info Schema ---
AttachmentInfo:
type: object
description: Metadata about an uploaded attachment.
properties:
fileId:
type: string
description: Unique storage identifier/path for the file (used for deletion).
fileName:
type: string
description: Original name of the uploaded file.
fileUrl:
type: string
format: url
description: URL to access the uploaded file (e.g., a signed GCS URL).
contentType:
type: string
description: MIME type of the uploaded file.
size:
type: integer
format: int64
description: Size of the uploaded file in bytes.
required:
- fileId
- fileName
- fileUrl
- contentType
- size
# --- Todo Schemas --- # --- Todo Schemas ---
Todo: Todo:
type: object type: object
description: Represents a Todo item. description: Represents a Todo item.
properties: properties:
id: id: { type: string, format: uuid, readOnly: true }
type: string userId: { type: string, format: uuid, readOnly: true }
format: uuid title: { type: string }
readOnly: true description: { type: string, nullable: true }
userId: status: { type: string, enum: [pending, in-progress, completed], default: pending }
type: string deadline: { type: string, format: date-time, nullable: true }
format: uuid tagIds:
readOnly: true
description: The ID of the user who owns this Todo.
title:
type: string
description: The main title or task of the Todo.
description:
type: string
nullable: true
description: Optional detailed description of the Todo.
status:
type: string
enum: [pending, in-progress, completed]
default: pending
description: Current status of the Todo item.
deadline:
type: string
format: date-time
nullable: true
description: Optional deadline for the Todo item.
tagIds: # <-- Added
type: array type: array
items: items: { type: string, format: uuid }
type: string
format: uuid
description: List of IDs of Tags associated with this Todo.
default: []
attachments:
type: array
items:
type: string
description: List of identifiers (e.g., URLs or IDs) for attached files/images. Managed via upload/update endpoints.
default: [] default: []
attachmentUrl: # <-- Changed from attachments array
type: string
format: url
nullable: true
description: Publicly accessible URL of the attached image, if any.
subtasks: subtasks:
type: array type: array
items: items: { $ref: '#/components/schemas/Subtask' }
$ref: '#/components/schemas/Subtask'
description: List of subtasks associated with this Todo. Usually fetched/managed via subtask endpoints.
readOnly: true # Subtasks typically managed via their own endpoints
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true readOnly: true
default: []
createdAt: { type: string, format: date-time, readOnly: true }
updatedAt: { type: string, format: date-time, readOnly: true }
required: required:
- id - id
- userId - userId
- title - title
- status - status
- tagIds # <-- Added - tagIds
- attachments
- createdAt - createdAt
- updatedAt - updatedAt
@ -296,7 +286,7 @@ components:
type: string type: string
format: date-time format: date-time
nullable: true nullable: true
tagIds: # <-- Added tagIds:
type: array type: array
items: items:
type: string type: string
@ -308,33 +298,16 @@ components:
UpdateTodoRequest: UpdateTodoRequest:
type: object type: object
description: Data for updating an existing Todo item. All fields are optional for partial updates. description: Data for updating an existing Todo item. Attachment is managed via dedicated endpoints.
properties: properties:
title: title: { type: string, minLength: 1 }
type: string description: { type: string, nullable: true }
minLength: 1 status: { type: string, enum: [pending, in-progress, completed] }
description: deadline: { type: string, format: date-time, nullable: true }
type: string tagIds:
nullable: true
status:
type: string
enum: [pending, in-progress, completed]
deadline:
type: string
format: date-time
nullable: true
tagIds: # <-- Added
type: array type: array
items: items: { type: string, format: uuid }
type: string
format: uuid
description: Replace the existing list of associated Tag IDs. IDs must belong to the user.
attachments: # Allow updating the list of attachments explicitly
type: array
items:
type: string
description: Replace the existing list of attachment identifiers. Use upload/delete endpoints for managing actual files.
# --- Subtask Schemas --- # --- Subtask Schemas ---
Subtask: Subtask:
type: object type: object
@ -392,34 +365,9 @@ components:
completed: completed:
type: boolean type: boolean
# --- File Upload Schemas --- # --- File Upload Response Schema ---
FileUploadResponse: FileUploadResponse: # This is the same as AttachmentInfo, could reuse definition with $ref
type: object $ref: '#/components/schemas/AttachmentInfo'
description: Response after successfully uploading a file.
properties:
fileId:
type: string
description: Unique identifier for the uploaded file.
fileName:
type: string
description: Original name of the uploaded file.
fileUrl:
type: string
format: url
description: URL to access the uploaded file.
contentType:
type: string
description: MIME type of the uploaded file.
size:
type: integer
format: int64
description: Size of the uploaded file in bytes.
required:
- fileId
- fileName
- fileUrl
- contentType
- size
# --- Error Schema --- # --- Error Schema ---
Error: Error:
@ -437,7 +385,6 @@ components:
- code - code
- message - message
# Reusable Responses
responses: responses:
BadRequest: BadRequest:
description: Invalid input (e.g., validation error, missing fields, invalid tag ID). description: Invalid input (e.g., validation error, missing fields, invalid tag ID).
@ -458,7 +405,7 @@ components:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
NotFound: NotFound:
description: The requested resource (e.g., Todo, Tag, Subtask) was not found. description: The requested resource (e.g., Todo, Tag, Subtask, Attachment) was not found.
content: content:
application/json: application/json:
schema: schema:
@ -476,13 +423,10 @@ components:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
# Security Requirement applied globally or per-operation
# Most endpoints require either Bearer or Cookie auth.
security: security:
- BearerAuth: [] - BearerAuth: []
- CookieAuth: [] - CookieAuth: []
# API Path Definitions
paths: paths:
# --- Authentication Endpoints --- # --- Authentication Endpoints ---
/auth/signup: /auth/signup:
@ -490,7 +434,7 @@ paths:
summary: Register a new user via email/password (API). summary: Register a new user via email/password (API).
operationId: signupUserApi operationId: signupUserApi
tags: [Auth] tags: [Auth]
security: [] # No auth required to sign up security: []
requestBody: requestBody:
required: true required: true
description: User details for registration. description: User details for registration.
@ -508,7 +452,7 @@ paths:
"400": "400":
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"409": "409":
$ref: "#/components/responses/Conflict" # e.g., Email or Username already exists $ref: "#/components/responses/Conflict"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
@ -518,7 +462,7 @@ paths:
description: Authenticates a user and returns a JWT access token in the response body for API clients. For browser clients, this endpoint typically also sets an HTTP-only cookie containing the JWT. description: Authenticates a user and returns a JWT access token in the response body for API clients. For browser clients, this endpoint typically also sets an HTTP-only cookie containing the JWT.
operationId: loginUserApi operationId: loginUserApi
tags: [Auth] tags: [Auth]
security: [] # No auth required to log in security: []
requestBody: requestBody:
required: true required: true
description: User credentials for login. description: User credentials for login.
@ -534,24 +478,23 @@ paths:
schema: schema:
$ref: "#/components/schemas/LoginResponse" $ref: "#/components/schemas/LoginResponse"
headers: headers:
Set-Cookie: # Indicate that a cookie might be set for browser clients Set-Cookie:
schema: schema:
type: string type: string
description: Contains the JWT authentication cookie (e.g., `jwt_token=...; HttpOnly; Secure; Path=/; SameSite=Lax`) description: Contains the JWT authentication cookie (e.g., `jwt_token=...; HttpOnly; Secure; Path=/; SameSite=Lax`)
"400": "400":
$ref: "#/components/responses/BadRequest" $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" # Invalid credentials $ref: "#/components/responses/Unauthorized"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
/auth/logout: # Often useful to have an explicit logout /auth/logout:
post: post:
summary: Log out the current user. summary: Log out the current user.
description: Invalidates the current session (e.g., clears the authentication cookie). description: Invalidates the current session (e.g., clears the authentication cookie).
operationId: logoutUser operationId: logoutUser
tags: [Auth] tags: [Auth]
# Requires authentication to know *who* is logging out to clear their session/cookie
security: security:
- BearerAuth: [] - BearerAuth: []
- CookieAuth: [] - CookieAuth: []
@ -559,12 +502,12 @@ paths:
"204": "204":
description: Logout successful. No content returned. description: Logout successful. No content returned.
headers: headers:
Set-Cookie: # Indicate that the cookie is being cleared Set-Cookie:
schema: schema:
type: string type: string
description: Clears the JWT authentication cookie (e.g., `jwt_token=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax`) description: Clears the JWT authentication cookie (e.g., `jwt_token=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax`)
"401": "401":
$ref: "#/components/responses/Unauthorized" # If not logged in initially $ref: "#/components/responses/Unauthorized"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
@ -574,7 +517,7 @@ paths:
description: Redirects the user's browser to Google's authentication page. Not a typical REST endpoint, part of the web flow. description: Redirects the user's browser to Google's authentication page. Not a typical REST endpoint, part of the web flow.
operationId: initiateGoogleLogin operationId: initiateGoogleLogin
tags: [Auth] tags: [Auth]
security: [] # No API auth needed to start the flow security: []
responses: responses:
"302": "302":
description: Redirect to Google's OAuth consent screen. The 'Location' header contains the redirect URL. description: Redirect to Google's OAuth consent screen. The 'Location' header contains the redirect URL.
@ -597,20 +540,7 @@ paths:
description: Google redirects the user here after authentication. The server exchanges the received code for tokens, finds/creates the user, generates a JWT, sets the auth cookie, and redirects the user (e.g., to the web app dashboard). description: Google redirects the user here after authentication. The server exchanges the received code for tokens, finds/creates the user, generates a JWT, sets the auth cookie, and redirects the user (e.g., to the web app dashboard).
operationId: handleGoogleCallback operationId: handleGoogleCallback
tags: [Auth] tags: [Auth]
security: [] # No API auth needed, Google provides auth code via query param security: []
# parameters:
# - name: code
# in: query
# required: true
# schema:
# type: string
# description: Authorization code provided by Google.
# - name: state
# in: query
# required: false # Recommended for security (CSRF protection)
# schema:
# type: string
# description: Opaque value used to maintain state between the request and callback.
responses: responses:
"302": "302":
description: Authentication successful. Redirects the user to the frontend application (e.g., '/dashboard'). Sets auth cookie. description: Authentication successful. Redirects the user to the frontend application (e.g., '/dashboard'). Sets auth cookie.
@ -680,15 +610,15 @@ paths:
schema: schema:
$ref: "#/components/schemas/User" $ref: "#/components/schemas/User"
"400": "400":
$ref: "#/components/responses/BadRequest" # Validation error $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"409": "409":
$ref: "#/components/responses/Conflict" # e.g. Username already taken $ref: "#/components/responses/Conflict"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
# --- Tag Endpoints --- <-- New Section # --- Tag Endpoints ---
/tags: /tags:
get: get:
summary: List all tags created by the current user. summary: List all tags created by the current user.
@ -732,11 +662,11 @@ paths:
schema: schema:
$ref: '#/components/schemas/Tag' $ref: '#/components/schemas/Tag'
"400": "400":
$ref: "#/components/responses/BadRequest" # Validation error $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"409": "409":
$ref: "#/components/responses/Conflict" # Tag name already exists for this user $ref: "#/components/responses/Conflict"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
@ -766,7 +696,7 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User does not own this tag $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"500": "500":
@ -793,15 +723,15 @@ paths:
schema: schema:
$ref: '#/components/schemas/Tag' $ref: '#/components/schemas/Tag'
"400": "400":
$ref: "#/components/responses/BadRequest" # Validation error $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User does not own this tag $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
$ref: "#/components/responses/Conflict" # New tag name already exists for this user $ref: "#/components/responses/Conflict"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
delete: delete:
@ -818,13 +748,12 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User does not own this tag $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
# --- Todo Endpoints --- # --- Todo Endpoints ---
/todos: /todos:
get: get:
@ -835,59 +764,14 @@ paths:
- BearerAuth: [] - BearerAuth: []
- CookieAuth: [] - CookieAuth: []
parameters: parameters:
- name: status - { name: status, in: query, required: false, schema: { type: string, enum: [pending, in-progress, completed] } }
in: query - { name: tagId, in: query, required: false, schema: { type: string, format: uuid } }
required: false - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, default: 20 } }
schema: - { name: offset, in: query, required: false, schema: { type: integer, minimum: 0, default: 0 } }
type: string
enum: [pending, in-progress, completed]
description: Filter Todos by status.
- name: tagId # <-- Added filter parameter
in: query
required: false
schema:
type: string
format: uuid
description: Filter Todos by a specific Tag ID.
- name: deadline_before
in: query
required: false
schema:
type: string
format: date-time
description: Filter Todos with deadline before this date/time.
- name: deadline_after
in: query
required: false
schema:
type: string
format: date-time
description: Filter Todos with deadline after this date/time.
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
default: 20
description: Maximum number of Todos to return.
- name: offset
in: query
required: false
schema:
type: integer
minimum: 0
default: 0
description: Number of Todos to skip for pagination.
responses: responses:
"200": "200":
description: A list of Todo items matching the criteria. description: A list of Todo items.
content: content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/Todo" } } } }
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Todo"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"500": "500":
@ -896,57 +780,35 @@ paths:
summary: Create a new Todo item. summary: Create a new Todo item.
operationId: createTodo operationId: createTodo
tags: [Todos] tags: [Todos]
security:
- BearerAuth: []
- CookieAuth: []
requestBody: requestBody:
required: true required: true
description: Todo item details to create, optionally including Tag IDs. content: { application/json: { schema: { $ref: "#/components/schemas/CreateTodoRequest" } } }
content:
application/json:
schema:
$ref: "#/components/schemas/CreateTodoRequest" # Now includes tagIds
responses: responses:
"201": "201":
description: Todo item created successfully. Returns the new Todo. description: Todo item created successfully.
content: content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
application/json:
schema:
$ref: "#/components/schemas/Todo"
"400": "400":
$ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
/todos/{todoId}: /todos/{todoId}:
parameters: # Parameter applicable to all methods for this path parameters:
- name: todoId - { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
in: path
required: true
schema:
type: string
format: uuid
description: ID of the Todo item.
get: get:
summary: Get a specific Todo item by ID. summary: Get a specific Todo item by ID.
operationId: getTodoById operationId: getTodoById
tags: [Todos] tags: [Todos]
security:
- BearerAuth: []
- CookieAuth: []
responses: responses:
"200": "200":
description: The requested Todo item. description: The requested Todo item.
content: content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
application/json:
schema:
$ref: "#/components/schemas/Todo" # Now includes tagIds
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"500": "500":
@ -955,29 +817,19 @@ paths:
summary: Update a specific Todo item by ID. summary: Update a specific Todo item by ID.
operationId: updateTodoById operationId: updateTodoById
tags: [Todos] tags: [Todos]
security:
- BearerAuth: []
- CookieAuth: []
requestBody: requestBody:
required: true required: true
description: Fields of the Todo item to update, potentially including the list of Tag IDs. content: { application/json: { schema: { $ref: "#/components/schemas/UpdateTodoRequest" } } }
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateTodoRequest" # Now includes tagIds
responses: responses:
"200": "200":
description: Todo item updated successfully. Returns the updated Todo. description: Todo item updated successfully.
content: content: { application/json: { schema: { $ref: "#/components/schemas/Todo" } } }
application/json:
schema:
$ref: "#/components/schemas/Todo"
"400": "400":
$ref: "#/components/responses/BadRequest" # e.g., invalid tag ID provided $ref: "#/components/responses/BadRequest"
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"500": "500":
@ -995,7 +847,7 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"500": "500":
@ -1004,62 +856,44 @@ paths:
# --- Attachment Endpoints --- # --- Attachment Endpoints ---
/todos/{todoId}/attachments: /todos/{todoId}/attachments:
parameters: parameters:
- name: todoId - { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
in: path
required: true
schema:
type: string
format: uuid
description: ID of the Todo item to attach the file to.
post: post:
summary: Upload a file and attach it to a Todo item. summary: Upload or replace the image attachment for a Todo item.
operationId: uploadTodoAttachment operationId: uploadOrReplaceTodoAttachment # Renamed for clarity
tags: [Attachments, Todos] tags: [Attachments, Todos]
security:
- BearerAuth: []
- CookieAuth: []
requestBody: requestBody:
required: true required: true
description: The file to upload. description: The image file to upload.
content: content:
multipart/form-data: multipart/form-data:
schema: schema:
type: object type: object
properties: properties: { file: { type: string, format: binary } }
file: # Name of the form field for the file required: [file]
type: string
format: binary
required:
- file
# You might add examples or encoding details here if needed
responses: responses:
"201": "201": # Use 201 Created (or 200 OK if replacing)
description: File uploaded and attached successfully. Returns file details. The Todo's `attachments` array is updated server-side. description: Image uploaded/replaced successfully. Returns file details.
content: content: { application/json: { schema: { $ref: '#/components/schemas/FileUploadResponse' } } } # Reusing this schema
application/json: "400": { $ref: "#/components/responses/BadRequest" } # Invalid file type, size limit etc.
schema: "401": { $ref: "#/components/responses/Unauthorized" }
$ref: '#/components/schemas/FileUploadResponse' "403": { $ref: "#/components/responses/Forbidden" }
"400": "404": { $ref: "#/components/responses/NotFound" } # Todo not found
$ref: "#/components/responses/BadRequest" # e.g., No file, size limit exceeded, invalid file type "500": { $ref: "#/components/responses/InternalServerError" }
"401": delete:
$ref: "#/components/responses/Unauthorized" summary: Delete the image attachment from a Todo item.
"403": operationId: deleteTodoAttachment # Reused name is fine
$ref: "#/components/responses/Forbidden" # User doesn't own this Todo tags: [Attachments, Todos]
"404": responses:
$ref: "#/components/responses/NotFound" # Todo not found "204": { description: Attachment deleted successfully. }
"500": "401": { $ref: "#/components/responses/Unauthorized" }
$ref: "#/components/responses/InternalServerError" # File storage error, etc. "403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" } # Todo or attachment not found
"500": { $ref: "#/components/responses/InternalServerError" }
# --- Subtask Endpoints --- # --- Subtask Endpoints ---
/todos/{todoId}/subtasks: /todos/{todoId}/subtasks:
parameters: parameters:
- name: todoId - { name: todoId, in: path, required: true, schema: { type: string, format: uuid } }
in: path
required: true
schema:
type: string
format: uuid
description: ID of the parent Todo item.
get: get:
summary: List all subtasks for a specific Todo item. summary: List all subtasks for a specific Todo item.
operationId: listSubtasksForTodo operationId: listSubtasksForTodo
@ -1079,9 +913,9 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" # Parent Todo not found $ref: "#/components/responses/NotFound"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
post: post:
@ -1110,9 +944,9 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" # Parent Todo not found $ref: "#/components/responses/NotFound"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
@ -1158,9 +992,9 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" # Todo or Subtask not found $ref: "#/components/responses/NotFound"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"
delete: delete:
@ -1176,8 +1010,8 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" # User doesn't own parent Todo $ref: "#/components/responses/Forbidden"
"404": "404":
$ref: "#/components/responses/NotFound" # Todo or Subtask not found $ref: "#/components/responses/NotFound"
"500": "500":
$ref: "#/components/responses/InternalServerError" $ref: "#/components/responses/InternalServerError"

View File

@ -100,4 +100,5 @@ export const Icons = {
loader: IconLoader, loader: IconLoader,
circle: IconCircle, circle: IconCircle,
moreVertical: IconDotsVertical, moreVertical: IconDotsVertical,
file: IconFile,
}; };

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; // import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
@ -15,45 +15,45 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Icons } from "@/components/icons"; import { Icons } from "@/components/icons";
import { Badge } from "@/components/ui/badge"; // import { Badge } from "@/components/ui/badge";
export function Navbar() { export function Navbar() {
const pathname = usePathname(); const pathname = usePathname();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [notificationCount, setNotificationCount] = useState(3); // const [notificationCount, setNotificationCount] = useState(3);
const notifications = [ // const notifications = [
{ // {
id: 1, // id: 1,
title: "New task assigned", // title: "New task assigned",
description: "You have been assigned a new task", // description: "You have been assigned a new task",
time: "5 minutes ago", // time: "5 minutes ago",
read: false, // read: false,
}, // },
{ // {
id: 2, // id: 2,
title: "Task completed", // title: "Task completed",
description: "Your task 'Update documentation' has been completed", // description: "Your task 'Update documentation' has been completed",
time: "1 hour ago", // time: "1 hour ago",
read: false, // read: false,
}, // },
{ // {
id: 3, // id: 3,
title: "Meeting reminder", // title: "Meeting reminder",
description: "Team meeting starts in 30 minutes", // description: "Team meeting starts in 30 minutes",
time: "2 hours ago", // time: "2 hours ago",
read: false, // read: false,
}, // },
]; // ];
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const markAsRead = (id: number) => { // const markAsRead = (id: number) => {
setNotificationCount(Math.max(0, notificationCount - 1)); // setNotificationCount(Math.max(0, notificationCount - 1));
}; // };
const markAllAsRead = () => { // const markAllAsRead = () => {
setNotificationCount(0); // setNotificationCount(0);
}; // };
return ( return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@ -110,7 +110,7 @@ export function Navbar() {
</div> </div>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end"> <div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
{/* Notifications Dropdown */} {/* Notifications Dropdown */}
<DropdownMenu> {/* <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
@ -173,7 +173,7 @@ export function Navbar() {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu> */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@ -1,16 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { import { Card, CardContent, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardTitle,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -26,6 +20,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { TodoForm } from "@/components/todo-form"; import { TodoForm } from "@/components/todo-form";
import { Icons } from "@/components/icons"; import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -35,8 +30,9 @@ import Image from "next/image";
interface TodoCardProps { interface TodoCardProps {
todo: Todo; todo: Todo;
tags: Tag[]; tags: Tag[];
onUpdate: (todo: Partial<Todo>) => void; onUpdate: (todo: Partial<Todo>) => Promise<void>;
onDelete: () => void; onDelete: () => void;
onAttachmentsChanged?: (attachments: string[]) => void;
isDraggable?: boolean; isDraggable?: boolean;
} }
@ -45,6 +41,7 @@ export function TodoCard({
tags, tags,
onUpdate, onUpdate,
onDelete, onDelete,
onAttachmentsChanged,
isDraggable = false, isDraggable = false,
}: TodoCardProps) { }: TodoCardProps) {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@ -69,7 +66,7 @@ export function TodoCard({
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
}; };
// Style helpers // --- Helper Functions ---
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case "pending": case "pending":
@ -82,175 +79,175 @@ export function TodoCard({
return "border-l-4 border-l-slate-400"; return "border-l-4 border-l-slate-400";
} }
}; };
// const getStatusIcon = (status: string) => {
const getStatusIcon = (status: string) => { // switch (status) {
switch (status) { // case "pending":
case "pending": // return <Icons.clock className="h-5 w-5 text-amber-500" />;
return <Icons.clock className="h-5 w-5 text-amber-500" />; // case "in-progress":
case "in-progress": // return <Icons.loader className="h-5 w-5 text-sky-500" />;
return <Icons.loader className="h-5 w-5 text-sky-500" />; // case "completed":
case "completed": // return <Icons.checkSquare className="h-5 w-5 text-emerald-500" />;
return <Icons.checkSquare className="h-5 w-5 text-emerald-500" />; // default:
default: // return <Icons.circle className="h-5 w-5 text-slate-400" />;
return <Icons.circle className="h-5 w-5 text-slate-400" />; // }
} // };
};
const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id));
const hasImage = !!todo.image;
const hasAttachments = todo.attachments && todo.attachments.length > 0;
const hasSubtasks = todo.subtasks && todo.subtasks.length > 0;
const completedSubtasks = todo.subtasks
? todo.subtasks.filter((subtask) => subtask.completed).length
: 0;
const handleStatusToggle = () => {
const newStatus = todo.status === "completed" ? "pending" : "completed";
onUpdate({ status: newStatus });
};
const formatDate = (dateString?: string | null) => { const formatDate = (dateString?: string | null) => {
if (!dateString) return ""; if (!dateString) return "";
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString(); return date.toLocaleDateString();
}; };
const todoTags = tags.filter((tag) => todo.tagIds?.includes(tag.id));
const hasAttachments = !!todo.attachmentUrl;
const hasSubtasks = todo.subtasks && todo.subtasks.length > 0;
const completedSubtasks =
todo.subtasks?.filter((s) => s.completed).length ?? 0;
// --- Event Handlers ---
const handleStatusToggle = () => {
const newStatus = todo.status === "completed" ? "pending" : "completed";
onUpdate({ status: newStatus });
};
// --- Rendering ---
const coverImage =
todo.attachmentUrl &&
todo.attachmentUrl.match(/\.(jpg|jpeg|png|gif|webp)$/i)
? todo.attachmentUrl
: null;
return ( return (
<> <>
<Card <Card
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( className={cn(
"transition-colors", "transition-shadow duration-150 ease-out",
getStatusColor(todo.status), getStatusColor(todo.status),
"shadow-sm min-w-[220px] max-w-[420px]", "shadow-sm hover:shadow-md min-w-[220px] max-w-[420px] bg-card",
isDraggable ? "cursor-grab active:cursor-grabbing" : "", isDraggable ? "cursor-grab active:cursor-grabbing" : "",
isDragging ? "shadow-lg" : "" isDragging
? "shadow-lg ring-2 ring-primary ring-opacity-50 scale-105 z-10"
: ""
)} )}
{...(isDraggable ? { ...attributes, ...listeners } : {})} {...(isDraggable ? { ...attributes, ...listeners } : {})}
onClick={() => !isDraggable && setIsViewDialogOpen(true)}
> >
<CardContent className="p-4 flex gap-4"> <CardContent className="p-3">
{/* Left icon, like notification card */} {/* Optional Cover Image */}
<div className="flex-shrink-0 mt-1">{getStatusIcon(todo.status)}</div> {coverImage && (
{/* Main content */} <div className="relative h-24 w-full mb-2 rounded overflow-hidden">
<div className="flex-grow"> <Image
<CardHeader className="p-0"> src={coverImage || "/placeholder.svg"}
<div className="flex items-center justify-between"> alt="Todo Attachment"
<CardTitle fill
className={cn( style={{ objectFit: "cover" }}
"text-base", sizes="(max-width: 640px) 100vw, 300px"
todo.status === "completed" && className="bg-muted"
"line-through text-muted-foreground" priority={false}
)} />
> </div>
{todo.title} )}
</CardTitle> {/* Title and Menu */}
{!isDraggable && ( <div className="flex items-start justify-between mb-1">
<DropdownMenu> <CardTitle
<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
className={cn( className={cn(
"mt-1", "text-sm font-medium leading-snug pr-2",
todo.status === "completed" && todo.status === "completed" &&
"line-through text-muted-foreground/70" "line-through text-muted-foreground"
)} )}
> >
{todo.description} {todo.title}
</CardDescription> </CardTitle>
{/* Tags and indicators */} {!isDraggable && (
<div className="flex flex-wrap items-center gap-1.5 mt-2"> <DropdownMenu
{todoTags.map((tag) => ( onOpenChange={(open) => open && setIsViewDialogOpen(false)}
<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.status === "completed" ? ( <DropdownMenuTrigger asChild>
<Icons.x className="h-3 w-3" /> <Button
) : ( variant="ghost"
<Icons.check className="h-3 w-3" /> size="icon"
)} className="h-6 w-6 flex-shrink-0 -mt-1 -mr-1"
</Button> >
</div> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -261,17 +258,17 @@ export function TodoCard({
<DialogHeader className="space-y-1"> <DialogHeader className="space-y-1">
<DialogTitle className="text-xl">Edit Todo</DialogTitle> <DialogTitle className="text-xl">Edit Todo</DialogTitle>
<DialogDescription className="text-sm"> <DialogDescription className="text-sm">
Make changes to your task and save when you&apos;re done Make changes to your task, add attachments, and save.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<TodoForm <TodoForm
todo={todo} todo={todo}
tags={tags} tags={tags}
onSubmit={(updatedTodo) => { onSubmit={async (updatedTodoData) => {
onUpdate(updatedTodo); await onUpdate(updatedTodoData);
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
toast.success("Todo updated successfully");
}} }}
onAttachmentsChanged={onAttachmentsChanged}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -283,7 +280,7 @@ export function TodoCard({
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Badge <Badge
className={cn( className={cn(
"px-2.5 py-1 capitalize font-medium text-sm", "px-2.5 py-1 capitalize font-medium text-xs",
todo.status === "pending" todo.status === "pending"
? "bg-amber-50 text-amber-700" ? "bg-amber-50 text-amber-700"
: todo.status === "in-progress" : todo.status === "in-progress"
@ -295,14 +292,8 @@ export function TodoCard({
> >
<div <div
className={cn( className={cn(
"w-2.5 h-2.5 rounded-full mr-1.5", "w-2 h-2 rounded-full mr-1.5",
todo.status === "pending" getStatusColor(todo.status).replace("border-l-4 ", "bg-")
? "bg-amber-500"
: todo.status === "in-progress"
? "bg-sky-500"
: todo.status === "completed"
? "bg-emerald-500"
: "bg-slate-400"
)} )}
/> />
{todo.status.replace("-", " ")} {todo.status.replace("-", " ")}
@ -324,10 +315,11 @@ export function TodoCard({
Created {formatDate(todo.createdAt)} Created {formatDate(todo.createdAt)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{hasImage && ( {/* Cover Image */}
<div className="w-full h-48 overflow-hidden relative"> {coverImage && (
<div className="w-full h-48 overflow-hidden relative bg-muted">
<Image <Image
src={todo.image || "/placeholder.svg?height=192&width=450"} src={coverImage || "/placeholder.svg"}
alt={todo.title} alt={todo.title}
fill fill
style={{ objectFit: "cover" }} style={{ objectFit: "cover" }}
@ -336,15 +328,20 @@ export function TodoCard({
/> />
</div> </div>
)} )}
<div className="px-6 py-4 space-y-4"> {/* Content Section */}
<div className="px-6 py-4 space-y-4 max-h-[50vh] overflow-y-auto">
{/* Description */}
{todo.description && ( {todo.description && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{todo.description} {todo.description}
</div> </div>
)} )}
{/* Tags */}
{todoTags.length > 0 && ( {todoTags.length > 0 && (
<div> <div>
<h4 className="text-xs font-medium mb-1.5">Tags</h4> <h4 className="text-xs font-medium text-muted-foreground mb-1.5">
TAGS
</h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{todoTags.map((tag) => ( {todoTags.map((tag) => (
<Badge <Badge
@ -362,26 +359,11 @@ export function TodoCard({
</div> </div>
</div> </div>
)} )}
{hasAttachments && ( {/* Subtasks */}
<div>
<h4 className="text-xs font-medium mb-1.5">Attachments</h4>
<div className="space-y-1">
{todo.attachments.map((a, i) => (
<div
key={i}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<Icons.paperclip className="h-3.5 w-3.5" />
<span>{a}</span>
</div>
))}
</div>
</div>
)}
{hasSubtasks && ( {hasSubtasks && (
<div> <div>
<h4 className="text-xs font-medium mb-1.5"> <h4 className="text-xs font-medium text-muted-foreground mb-1.5">
Subtasks ({completedSubtasks}/{todo.subtasks.length}) SUBTASKS ({completedSubtasks}/{todo.subtasks.length})
</h4> </h4>
<ul className="space-y-1.5 text-sm"> <ul className="space-y-1.5 text-sm">
{todo.subtasks.map((subtask) => ( {todo.subtasks.map((subtask) => (
@ -413,8 +395,42 @@ export function TodoCard({
</ul> </ul>
</div> </div>
)} )}
{/* Attachments */}
{hasAttachments && (
<div>
<h4 className="text-xs font-medium text-muted-foreground mb-1.5">
ATTACHMENT
</h4>
<div className="space-y-2">
{todo.attachmentUrl && (
<div className="flex items-center justify-between p-2 rounded-md border bg-muted/50">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-8 h-8 rounded overflow-hidden flex-shrink-0 bg-background">
<Image
src={todo.attachmentUrl}
alt={todo.title}
width={32}
height={32}
className="object-cover"
/>
</div>
<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>
<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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -429,7 +445,7 @@ export function TodoCard({
setIsEditDialogOpen(true); setIsEditDialogOpen(true);
}} }}
> >
Edit <Icons.edit className="h-3.5 w-3.5 mr-1.5" /> Edit Todo
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -1,9 +1,10 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import Image from "next/image";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
import { uploadAttachment, deleteAttachment } from "@/services/api-attachments";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -16,25 +17,33 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { MultiSelect } from "@/components/multi-select"; import { MultiSelect } from "@/components/multi-select";
import { Icons } from "@/components/icons";
import type { Todo, Tag } from "@/services/api-types"; import type { Todo, Tag } from "@/services/api-types";
import { Progress } from "./ui/progress";
interface TodoFormProps { interface TodoFormProps {
todo?: Todo; todo?: Todo;
tags: Tag[]; tags: Tag[];
onSubmit: (todo: Partial<Todo>) => void; onSubmit: (todo: Partial<Todo>) => Promise<void>;
onAttachmentsChanged?: (attachments: string[]) => void; // Now just an array of one or zero
} }
export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) { export function TodoForm({
todo,
tags,
onSubmit,
onAttachmentsChanged,
}: TodoFormProps) {
const { token } = useAuth();
const [formData, setFormData] = useState<Partial<Todo>>({ const [formData, setFormData] = useState<Partial<Todo>>({
title: "", title: "",
description: "", description: "",
status: "pending", status: "pending",
deadline: undefined, deadline: undefined,
tagIds: [], tagIds: [],
image: null, attachmentUrl: null,
}); });
const [imagePreview, setImagePreview] = useState<string | null>(null); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
useEffect(() => { useEffect(() => {
if (todo) { if (todo) {
@ -44,12 +53,17 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
status: todo.status, status: todo.status,
deadline: todo.deadline, deadline: todo.deadline,
tagIds: todo.tagIds || [], tagIds: todo.tagIds || [],
image: todo.image || null, attachmentUrl: todo.attachmentUrl || null,
});
} else {
setFormData({
title: "",
description: "",
status: "pending",
deadline: undefined,
tagIds: [],
attachmentUrl: null,
}); });
if (todo.image) {
setImagePreview(todo.image);
}
} }
}, [todo]); }, [todo]);
@ -68,29 +82,85 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
setFormData((prev) => ({ ...prev, tagIds: selected })); setFormData((prev) => ({ ...prev, tagIds: selected }));
}; };
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { // Upload new attachment
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
// Only one attachment is supported, so uploading a new one replaces the old
if (!todo || !token) {
toast.error("Cannot attach files until the todo is saved.");
return;
}
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
// Only allow images
if (!file.type.startsWith("image/")) {
toast.error("Only image files are allowed.");
return;
}
// In a real app, we would upload the file to a server and get a URL back setIsUploading(true);
// For now, we'll create a local object URL setUploadProgress(0);
const imageUrl = URL.createObjectURL(file);
setImagePreview(imageUrl); // Simulate progress for demo replace if backend supports it
setFormData((prev) => ({ ...prev, image: imageUrl })); const progressInterval = setInterval(() => {
setUploadProgress((p) => Math.min(p + 10, 90));
}, 200);
try {
const response = await uploadAttachment(todo.id, file, token);
clearInterval(progressInterval);
setUploadProgress(100);
toast.success(`Uploaded: "${response.fileName}"`);
setFormData((f) => ({ ...f, attachmentUrl: response.fileUrl }));
onAttachmentsChanged?.([response.fileUrl]);
setTimeout(() => {
setIsUploading(false);
setUploadProgress(0);
}, 500);
} catch (err) {
clearInterval(progressInterval);
setIsUploading(false);
setUploadProgress(0);
console.error(err);
toast.error(
`Upload failed: ${err instanceof Error ? err.message : "Unknown error"}`
);
} finally {
e.target.value = "";
}
}; };
const handleRemoveImage = () => { // Remove an existing attachment
setImagePreview(null); const handleRemoveAttachment = async (attachmentId: string) => {
setFormData((prev) => ({ ...prev, image: null })); // Only one attachment is supported, so attachmentId is ignored
if (!todo || !token) {
toast.error("Cannot remove attachments right now.");
return;
}
try {
await deleteAttachment(todo.id, attachmentId, token);
// Only one attachment is supported now, so just clear the attachmentUrl
setFormData((f) => ({ ...f, attachmentUrl: null }));
onAttachmentsChanged?.([]);
toast.success("Attachment removed.");
} catch (err) {
console.error(err);
toast.error(
`Delete failed: ${err instanceof Error ? err.message : "Unknown error"}`
);
}
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
onSubmit(formData); await onSubmit(formData);
}; };
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Title, Description, Status, Deadline, Tags... */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title</Label> <Label htmlFor="title">Title</Label>
<Input <Input
@ -150,46 +220,50 @@ export function TodoForm({ todo, tags, onSubmit }: TodoFormProps) {
placeholder="Select tags" placeholder="Select tags"
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="image">Image (optional)</Label> {/* Attachment Section - Only shown when editing an existing todo */}
<div className="flex items-center gap-2"> {todo && (
<div className="space-y-2">
<Label htmlFor="attachments">Attachments</Label>
<Input <Input
id="image" id="file"
name="image"
type="file" type="file"
accept="image/*" accept="image/*"
onChange={handleImageChange} multiple={false}
className="flex-1" onChange={handleFileChange}
disabled={isUploading}
/> />
{imagePreview && ( {isUploading && (
<Button <Progress value={uploadProgress} className="w-full h-2" />
type="button"
variant="outline"
size="icon"
onClick={handleRemoveImage}
>
<Icons.trash className="h-4 w-4" />
<span className="sr-only">Remove image</span>
</Button>
)} )}
</div> </div>
{imagePreview && ( )}
<div className="mt-2 relative w-full h-32 rounded-md overflow-hidden border"> {formData.attachmentUrl && (
<Image <div className="mt-2">
src={imagePreview || "/placeholder.svg"} <ul>
alt="Preview" <li className="flex items-center gap-2">
width={400} <a
height={128} href={formData.attachmentUrl}
className="w-full h-full object-cover rounded-md" target="_blank"
/> rel="noopener noreferrer"
</div> className="text-blue-600 underline"
)} >
</div> View Attachment
<Button </a>
type="submit" <Button
className="w-full bg-[#FF5A5F] hover:bg-[#FF5A5F]/90" size="sm"
> variant="ghost"
{todo ? "Update" : "Create"} Todo 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> </Button>
</form> </form>
); );

View File

@ -1,56 +1,116 @@
"use client" "use client";
import { useState } from "react" import { useEffect, useState } from "react";
import { toast } from "sonner" import { toast } from "sonner";
import { useSortable } from "@dnd-kit/sortable" import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities" import { CSS } from "@dnd-kit/utilities";
import { Badge } from "@/components/ui/badge" import Image from "next/image"; // Import Image
import { Button } from "@/components/ui/button" import { useAuth } from "@/hooks/use-auth";
import { Checkbox } from "@/components/ui/checkbox" import { deleteAttachment } from "@/services/api-attachments";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge";
import { TodoForm } from "@/components/todo-form" import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons" import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils" import {
import type { Todo, Tag } from "@/services/api-types" Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; // Import AlertDialog
import { TodoForm } from "@/components/todo-form";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import type { Todo, Tag } from "@/services/api-types";
interface TodoRowProps { interface TodoRowProps {
todo: Todo todo: Todo;
tags: Tag[] tags: Tag[];
onUpdate: (todo: Partial<Todo>) => void onUpdate: (todo: Partial<Todo>) => Promise<void>; // Make async
onDelete: () => void onDelete: () => void;
isDraggable?: boolean onAttachmentsChanged?: (attachments: AttachmentInfo[]) => void; // Add callback
isDraggable?: boolean;
} }
export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }: TodoRowProps) { export function TodoRow({
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) todo,
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false) tags,
onUpdate,
onDelete,
onAttachmentsChanged,
isDraggable = false,
}: TodoRowProps) {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
const { token } = useAuth();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: todo.id, id: todo.id,
disabled: !isDraggable, disabled: !isDraggable,
}) });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
} };
const todoTags = tags.filter((tag) => todo.tagIds.includes(tag.id)) const todoTags = tags.filter((tag) => todo.tagIds?.includes(tag.id));
const hasAttachments = todo.attachments && todo.attachments.length > 0;
const handleStatusToggle = () => { const handleStatusToggle = () => {
const newStatus = todo.status === "completed" ? "pending" : "completed" const newStatus = todo.status === "completed" ? "pending" : "completed";
onUpdate({ status: newStatus }) onUpdate({ status: newStatus });
} };
const formatDate = (dateString?: string | null) => { const formatDate = (dateString?: string | null) => {
if (!dateString) return null if (!dateString) return null;
const date = new Date(dateString) const date = new Date(dateString);
return date.toLocaleDateString() return date.toLocaleDateString();
} };
// Check if todo has image or attachments const handleDeleteAttachment = async (attachmentId: string) => {
const hasAttachments = todo.attachments && todo.attachments.length > 0 if (!token) {
toast.error("Authentication required.");
return;
}
try {
await deleteAttachment(todo.id, attachmentId, token);
toast.success("Attachment deleted.");
const updatedAttachments = todo.attachments.filter(
(att) => att.fileId !== attachmentId
);
onAttachmentsChanged?.(updatedAttachments);
// Also update the local state for the dialog if it's open
setFormData((prev) => ({ ...prev, attachments: updatedAttachments }));
} catch (error) {
console.error("Failed to delete attachment:", error);
toast.error("Failed to delete attachment.");
}
};
// State to manage form data within the row component context if needed, e.g., for the view dialog
const [formData, setFormData] = useState(todo);
useEffect(() => {
setFormData(todo);
}, [todo]);
return ( return (
<> <>
@ -61,170 +121,387 @@ export function TodoRow({ todo, tags, onUpdate, onDelete, isDraggable = false }:
"group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 rounded-md transition-colors", "group flex items-center gap-3 px-4 py-2 hover:bg-muted/50 rounded-md transition-colors",
todo.status === "completed" ? "text-muted-foreground" : "", todo.status === "completed" ? "text-muted-foreground" : "",
isDraggable ? "cursor-grab active:cursor-grabbing" : "", isDraggable ? "cursor-grab active:cursor-grabbing" : "",
isDragging ? "shadow-lg bg-muted" : "", isDragging ? "shadow-lg bg-muted" : ""
)} )}
{...(isDraggable ? { ...attributes, ...listeners } : {})} {...(isDraggable ? { ...attributes, ...listeners } : {})}
> >
<Checkbox <Checkbox
checked={todo.status === "completed"} checked={todo.status === "completed"}
onCheckedChange={() => handleStatusToggle()} onCheckedChange={() => handleStatusToggle()}
className="h-5 w-5 rounded-full" className="h-5 w-5 rounded-full flex-shrink-0" // Added flex-shrink-0
/> />
<div className="flex-1 min-w-0"> <div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => setIsViewDialogOpen(true)}
>
{" "}
{/* Make main area clickable */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn("text-sm font-medium truncate", todo.status === "completed" ? "line-through" : "")}> <span
className={cn(
"text-sm font-medium truncate",
todo.status === "completed" ? "line-through" : ""
)}
>
{todo.title} {todo.title}
</span> </span>
{todo.image && <Icons.image className="h-3.5 w-3.5 text-muted-foreground" />} {/* Indicators */}
{hasAttachments && <Icons.paperclip className="h-3.5 w-3.5 text-muted-foreground" />} <div className="flex items-center gap-1">
{hasAttachments && (
<Icons.paperclip className="h-3 w-3 text-muted-foreground" />
)}
{/* Add other indicators like subtasks if needed */}
</div>
</div> </div>
{todo.description && (
{todo.description && <p className="text-xs text-muted-foreground truncate">{todo.description}</p>} <p className="text-xs text-muted-foreground truncate mt-0.5">
{todo.description}
</p>
)}
</div> </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 && ( {todoTags.length > 0 && (
<div className="flex gap-1"> <div className="hidden sm:flex gap-1">
{" "}
{/* Hide tags on very small screens */}
{todoTags.slice(0, 2).map((tag) => ( {todoTags.slice(0, 2).map((tag) => (
<div <div
key={tag.id} key={tag.id}
className="w-2 h-2 rounded-full" className="w-2 h-2 rounded-full"
style={{ backgroundColor: tag.color || "#FF5A5F" }} style={{ backgroundColor: tag.color || "#FF5A5F" }}
title={tag.name}
/> />
))} ))}
{todoTags.length > 2 && <span className="text-xs text-muted-foreground">+{todoTags.length - 2}</span>} {todoTags.length > 2 && (
<span className="text-[10px] text-muted-foreground">
+{todoTags.length - 2}
</span>
)}
</div> </div>
)} )}
{/* Deadline */}
{todo.deadline && ( {todo.deadline && (
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(todo.deadline)}</span> <span className="text-xs text-muted-foreground whitespace-nowrap hidden md:inline">
{formatDate(todo.deadline)}
</span>
)} )}
{/* Action Buttons (Appear on Hover) */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<Button variant="ghost" size="icon" onClick={() => setIsViewDialogOpen(true)} className="h-7 w-7"> <Button
<Icons.eye className="h-3.5 w-3.5" /> variant="ghost"
size="icon"
onClick={() => setIsViewDialogOpen(true)}
className="h-7 w-7"
>
<Icons.eye className="h-3.5 w-3.5" />{" "}
<span className="sr-only">View</span> <span className="sr-only">View</span>
</Button> </Button>
<Button variant="ghost" size="icon" onClick={() => setIsEditDialogOpen(true)} className="h-7 w-7"> <Button
<Icons.edit className="h-3.5 w-3.5" /> variant="ghost"
size="icon"
onClick={() => setIsEditDialogOpen(true)}
className="h-7 w-7"
>
<Icons.edit className="h-3.5 w-3.5" />{" "}
<span className="sr-only">Edit</span> <span className="sr-only">Edit</span>
</Button> </Button>
<Button variant="ghost" size="icon" onClick={onDelete} className="h-7 w-7"> {/* Delete Confirmation */}
<Icons.trash className="h-3.5 w-3.5" /> <AlertDialog>
<span className="sr-only">Delete</span> <AlertDialogTrigger asChild>
</Button> <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 &quot;{todo.title}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</div> </div>
</div> </div>
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}> <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Todo</DialogTitle> <DialogTitle>Edit Todo</DialogTitle>
<DialogDescription>Update your todo details</DialogDescription> <DialogDescription>
Update your todo details and attachments.
</DialogDescription>
</DialogHeader> </DialogHeader>
<TodoForm <TodoForm
todo={todo} todo={todo}
tags={tags} tags={tags}
onSubmit={(updatedTodo) => { onSubmit={async (updatedTodoData) => {
onUpdate(updatedTodo) await onUpdate(updatedTodoData);
setIsEditDialogOpen(false) setIsEditDialogOpen(false);
toast.success("Todo updated successfully") }}
onAttachmentsChanged={(newAttachments) => {
// If the parent needs immediate notification of attachment changes
onAttachmentsChanged?.(newAttachments);
// Optionally update local state if needed within this component context
setFormData((prev) => ({ ...prev, attachments: newAttachments }));
}} }}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* View Dialog */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}> <Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="sm:max-w-[500px]"> {/* Re-use the View Dialog structure from TodoCard, adapting as needed */}
<DialogHeader> <DialogContent className="sm:max-w-[450px] p-0 overflow-hidden">
<DialogTitle>{todo.title}</DialogTitle> <DialogHeader className="px-6 pt-6 pb-2">
<DialogDescription>Created on {new Date(todo.createdAt).toLocaleDateString()}</DialogDescription> {/* ... Header content (status badge, title, etc.) ... */}
<div className="flex items-center gap-2 mb-2">
{/* Status Badge */}
<Badge
className={cn(
"px-2.5 py-1 capitalize font-medium text-xs",
todo.status === "pending"
? "bg-amber-50 text-amber-700"
: todo.status === "in-progress"
? "bg-sky-50 text-sky-700"
: todo.status === "completed"
? "bg-emerald-50 text-emerald-700"
: "bg-slate-50 text-slate-700"
)}
>
<div
className={cn(
"w-2 h-2 rounded-full mr-1.5",
todo.status === "pending"
? "bg-amber-500"
: todo.status === "in-progress"
? "bg-sky-500"
: todo.status === "completed"
? "bg-emerald-500"
: "bg-slate-400"
)}
/>
{todo.status.replace("-", " ")}
</Badge>
{/* Deadline Badge */}
{todo.deadline && (
<Badge
variant="outline"
className="text-xs font-normal px-2 py-0 ml-auto"
>
{" "}
<Icons.calendar className="h-3 w-3 mr-1" />{" "}
{formatDate(todo.deadline)}{" "}
</Badge>
)}
</div>
<DialogTitle className="text-xl font-semibold">
{formData.title}
</DialogTitle>
<DialogDescription className="text-xs mt-1">
Created {formatDate(formData.createdAt)}
</DialogDescription>
</DialogHeader> </DialogHeader>
{todo.image && (
<div className="w-full h-48 overflow-hidden rounded-md mb-4"> {/* Cover Image (find first image attachment) */}
<img {formData.attachments?.find((att) =>
src={todo.image || "/placeholder.svg?height=192&width=448"} att.contentType.startsWith("image/")
alt={todo.title} ) && (
className="w-full h-full object-cover" <div className="w-full h-48 overflow-hidden relative bg-muted">
<Image
src={
formData.attachments.find((att) =>
att.contentType.startsWith("image/")
)?.fileUrl || "/placeholder.svg"
}
alt={formData.title}
fill
style={{ objectFit: "cover" }}
sizes="450px"
/> />
</div> </div>
)} )}
<div className="space-y-4">
<div> <div className="px-6 py-4 space-y-4 max-h-[50vh] overflow-y-auto">
<h4 className="text-sm font-medium mb-1">Status</h4> {/* Description */}
<Badge {formData.description && (
className={cn( <div className="text-sm text-muted-foreground">
"text-white", {formData.description}
todo.status === "pending"
? "bg-yellow-500"
: todo.status === "in-progress"
? "bg-blue-500"
: "bg-green-500",
)}
>
{todo.status.replace("-", " ")}
</Badge>
</div>
{todo.deadline && (
<div>
<h4 className="text-sm font-medium mb-1">Deadline</h4>
<p className="text-sm flex items-center gap-1">
<Icons.calendar className="h-4 w-4" />
{formatDate(todo.deadline)}
</p>
</div>
)}
{todo.description && (
<div>
<h4 className="text-sm font-medium mb-1">Description</h4>
<p className="text-sm">{todo.description}</p>
</div> </div>
)} )}
{/* Tags */}
{todoTags.length > 0 && ( {todoTags.length > 0 && (
<div> <div>
<h4 className="text-sm font-medium mb-1">Tags</h4> {" "}
<div className="flex flex-wrap gap-2"> <h4 className="text-xs font-medium text-muted-foreground mb-1.5">
TAGS
</h4>{" "}
<div className="flex flex-wrap gap-1.5">
{todoTags.map((tag) => ( {todoTags.map((tag) => (
<Badge key={tag.id} style={{ backgroundColor: tag.color || "#FF5A5F" }} className="text-white"> <Badge
key={tag.id}
variant="outline"
className="px-2 py-0.5 text-xs font-normal border-0"
style={{
backgroundColor: `${tag.color}20` || "#FF5A5F20",
color: tag.color || "#FF5A5F",
}}
>
{tag.name} {tag.name}
</Badge> </Badge>
))} ))}
</div>{" "}
</div>
)}
{/* 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 &quot;
{att.fileName}&quot;? 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>
</div> </div>
)} )}
{hasAttachments && ( </div>
<div> {/* Footer Actions */}
<h4 className="text-sm font-medium mb-1">Attachments</h4> <div className="border-t px-6 py-3 bg-muted/30 flex justify-end gap-2">
<ul className="space-y-2"> <Button
{todo.attachments.map((attachment, index) => ( variant="outline"
<li key={index} className="flex items-center gap-2 text-sm"> size="sm"
<Icons.paperclip className="h-4 w-4" /> onClick={() => setIsViewDialogOpen(false)}
<span>{attachment}</span> >
</li> {" "}
))} Close{" "}
</ul> </Button>
</div> <Button
)} size="sm"
{todo.subtasks && todo.subtasks.length > 0 && ( onClick={() => {
<div> setIsViewDialogOpen(false);
<h4 className="text-sm font-medium mb-1">Subtasks</h4> setIsEditDialogOpen(true);
<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"}`} /> <Icons.edit className="h-3.5 w-3.5 mr-1.5" /> Edit Todo{" "}
<span className={subtask.completed ? "line-through text-muted-foreground" : ""}> </Button>
{subtask.description}
</span>
</li>
))}
</ul>
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
) );
} }

View File

@ -10,7 +10,7 @@ export function useTags() {
return useQuery({ return useQuery({
queryKey: ["tags"], queryKey: ["tags"],
queryFn: () => listUserTags(token), queryFn: () => listUserTags(token || undefined),
enabled: !!token, enabled: !!token,
}) })
} }

View File

@ -1,7 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: "https",
hostname: "storage.googleapis.com",
},
],
},
}; };
export default nextConfig; export default nextConfig;

View 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
);
}

View File

@ -1,84 +1,155 @@
// Base API client for making requests to the backend import { useAuthStore } from "@/store/auth-store";
// Helper for simulating network delay in development const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// Get the API base URL from environment variable or use default const API_BASE_URL =
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1" process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1";
// Request options with authentication token if available const getRequestOptions = (token?: string | null, isFormData = false) => {
const getRequestOptions = (token?: string | null) => { const headers: HeadersInit = {};
const headers: HeadersInit = {
"Content-Type": "application/json", if (!isFormData) {
headers["Content-Type"] = "application/json";
} }
if (token) { if (token) {
headers["Authorization"] = `Bearer ${token}` headers["Authorization"] = `Bearer ${token}`;
} }
return { headers } return { headers };
} };
// Generic fetch function with error handling async function apiFetch<T>(
export async function apiFetch<T>(
endpoint: string, endpoint: string,
options: RequestInit = {}, options: RequestInit = {},
token?: string | null, token?: string | null,
simulateDelay = process.env.NODE_ENV === "development" ? 300 : 0, // Only simulate delay in development isFormData = false,
simulateDelay = process.env.NODE_ENV === "development" ? 300 : 0
): Promise<T> { ): Promise<T> {
if (simulateDelay > 0) { if (simulateDelay > 0) {
await delay(simulateDelay) await delay(simulateDelay);
} }
const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}` const url = `${API_BASE_URL}${
endpoint.startsWith("/") ? endpoint : `/${endpoint}`
}`;
// Merge headers carefully
const baseOptions = getRequestOptions(token, isFormData);
const requestOptions = { const requestOptions = {
...options, ...options,
headers: { headers: {
...getRequestOptions(token).headers, ...baseOptions.headers, // Use headers from getRequestOptions
...options.headers, ...options.headers, // Allow overriding/adding headers from options
}, },
} };
const response = await fetch(url, requestOptions) const response = await fetch(url, requestOptions);
// Handle non-2xx responses
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})) const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`) throw new Error(
errorData.message || `API error: ${response.status} ${response.statusText}`
);
} }
// Handle empty responses (like for DELETE operations) // Handle empty responses (like 204 No Content)
const contentType = response.headers.get("content-type") if (response.status === 204) {
return {} as T;
}
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) { if (contentType?.includes("application/json")) {
return (await response.json()) as T return (await response.json()) as T;
} }
return {} as T // Handle other content types if necessary, or return raw response?
// For now, assume JSON or empty for successful non-error responses
return {} as T;
} }
// Convenience methods for common HTTP methods // Add a function specifically for FormData uploads
export const apiClient = { async function apiUpload<T>(
get: <T>(endpoint: string, token?: string | null) => endpoint: string,
apiFetch<T>(endpoint, { method: 'GET' }, token), formData: FormData,
token?: string | null
post: <T>(endpoint: string, data: unknown, token?: string | null) => ): Promise<T> {
apiFetch<T>(endpoint, { // Get token from store if not provided explicitly
method: 'POST', const authToken = token ?? useAuthStore.getState().token;
body: JSON.stringify(data)
}, token), const url = `${API_BASE_URL}${
endpoint.startsWith("/") ? endpoint : `/${endpoint}`
put: <T>(endpoint: string, data: unknown, token?: string | null) => }`;
apiFetch<T>(endpoint, {
method: 'PUT', const headers: HeadersInit = {};
body: JSON.stringify(data) if (authToken) {
}, token), headers["Authorization"] = `Bearer ${authToken}`;
}
patch: <T>(endpoint: string, data: unknown, token?: string | null) => // DO NOT set Content-Type header for FormData
apiFetch<T>(endpoint, {
method: 'PATCH', const response = await fetch(url, {
body: JSON.stringify(data) method: "POST",
}, token), body: formData,
headers: headers,
delete: <T>(endpoint: string, token?: string | null) => });
apiFetch<T>(endpoint, { method: 'DELETE' }, token),
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),
};

View File

@ -1,5 +1,3 @@
// Generated types based on the OpenAPI spec
export interface User { export interface User {
id: string id: string
username: string username: string
@ -59,8 +57,7 @@ export interface Todo {
status: "pending" | "in-progress" | "completed" status: "pending" | "in-progress" | "completed"
deadline?: string | null deadline?: string | null
tagIds: string[] tagIds: string[]
attachments: string[] attachmentUrl?: string | null
image?: string | null
subtasks: Subtask[] subtasks: Subtask[]
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@ -72,7 +69,6 @@ export interface CreateTodoRequest {
status?: "pending" | "in-progress" | "completed" status?: "pending" | "in-progress" | "completed"
deadline?: string | null deadline?: string | null
tagIds?: string[] tagIds?: string[]
image?: string | null
} }
export interface UpdateTodoRequest { export interface UpdateTodoRequest {
@ -81,8 +77,7 @@ export interface UpdateTodoRequest {
status?: "pending" | "in-progress" | "completed" status?: "pending" | "in-progress" | "completed"
deadline?: string | null deadline?: string | null
tagIds?: string[] tagIds?: string[]
attachments?: string[] // attachments are managed via separate endpoints, removed from here
image?: string | null
} }
export interface Subtask { export interface Subtask {
@ -103,15 +98,15 @@ export interface UpdateSubtaskRequest {
completed?: boolean completed?: boolean
} }
export interface FileUploadResponse { export type FileUploadResponse = {
fileId: string fileId: string // Identifier used for deletion (e.g., GCS object path)
fileName: string fileName: string // Original filename
fileUrl: string fileUrl: string // Publicly accessible URL (e.g., signed URL)
contentType: string contentType: string // MIME type
size: number size: number // Size in bytes
} }
export interface Error { export interface Error {
code: number code: number
message: string message: string
} }