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

View File

@ -56,14 +56,7 @@ func main() {
repoRegistry := repository.NewRepositoryRegistry(pool)
var storageService service.FileStorageService
switch cfg.Storage.Type {
case "local":
storageService, err = service.NewLocalStorageService(cfg.Storage.Local, logger)
case "gcs":
storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger)
default:
err = fmt.Errorf("unsupported storage type: %s", cfg.Storage.Type)
}
storageService, err = service.NewGCStorageService(cfg.Storage.GCS, logger)
if err != nil {
logger.Error("Failed to initialize storage service", "error", err, "type", cfg.Storage.Type)
os.Exit(1)

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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