diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b103045 --- /dev/null +++ b/go.sum @@ -0,0 +1,129 @@ +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/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU= +google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handler/default.go b/internal/api/handler/default.go deleted file mode 100644 index 2ae7f1b..0000000 --- a/internal/api/handler/default.go +++ /dev/null @@ -1,55 +0,0 @@ -package handler - -import ( - "log/slog" - "net/http" - - "github.com/robindittmar/dttmr-api/internal/api/response" -) - -type apiResponse struct { - Method string `json:"method"` - Url string `json:"url"` - Proto string `json:"proto"` - Header map[string]string `json:"header"` - Host string `json:"host"` - RemoteAddr string `json:"remoteAddr"` - Form map[string]string `json:"form"` -} - -// DefaultHandler handles the default route -// -// @Summary Default route handler -// @Description Default route handler -// @Tags -// @Accept json -// @Produce json -// @Success 200 {object} apiResponse -// @Router / [get] -func DefaultHandler(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - resp := apiResponse{ - Method: r.Method, - Url: r.URL.String(), - Proto: r.Proto, - Header: make(map[string]string), - Host: r.Host, - RemoteAddr: r.RemoteAddr, - Form: make(map[string]string), - } - - for k, v := range r.Header { - resp.Header[k] = v[0] - } - - if err := r.ParseForm(); err == nil { - for k, v := range r.Form { - resp.Form[k] = v[0] - } - } else { - slog.ErrorContext(ctx, "error parsing form", slog.Any("error", err)) - } - - response.JSON(ctx, w, http.StatusOK, resp) -} diff --git a/internal/api/handler/invite.go b/internal/api/handler/invite.go new file mode 100644 index 0000000..c8619eb --- /dev/null +++ b/internal/api/handler/invite.go @@ -0,0 +1,118 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/robindittmar/dttmr-api/internal/api/response" + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type InviteHandler struct { + InviteService *domain.InviteService +} + +func NewInviteHandler(inviteService *domain.InviteService) *InviteHandler { + return &InviteHandler{InviteService: inviteService} +} + +// CreateInvite handles the creation of an invitation +// +// @Summary Create invite route +// @Description Create an invitation, which may be used to register an account +// @Tags Invite +// @Accept json +// @Produce json +// @Success 201 {object} domain.Invite +// @Error 500 {object} response.ErrorResponse "failed to create invite" +// @Router /user/invites [post] +func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + authContext, err := domain.GetAuthContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to create invite") + return + } + + invite, err := h.InviteService.CreateInvite(ctx, authContext.UserID) + if err != nil { + slog.ErrorContext(ctx, "failed to create invite", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to create invite") + return + } + + slog.InfoContext(ctx, "created invite successfully", slog.String("invite_id", invite.ID)) + response.JSON(ctx, w, http.StatusCreated, invite) +} + +// DeleteInvite handles the deletion of an invitation +// +// @Summary Delete invitation route +// @Description Deletes an invitation. Invitations can only be deleted when they have not been used +// @Tags Invite +// @Accept json +// @Produce json +// @Param id path int true "Invite ID" +// @Success 204 +// @Error 400 {object} response.ErrorResponse "failed to decode request url" +// @Error 500 {object} response.ErrorResponse "failed to delete invite" +// @Router /user/invites/{id} [delete] +func (h *InviteHandler) DeleteInvite(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + inviteID := r.PathValue("id") + if inviteID == "" { + slog.ErrorContext(ctx, "failed to read invite id from path") + response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url") + return + } + + authContext, err := domain.GetAuthContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to delete invite") + return + } + + err = h.InviteService.DeleteInvite(ctx, authContext.UserID, inviteID) + if err != nil { + slog.ErrorContext(ctx, "failed to delete invite", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to delete invite") + return + } + + slog.InfoContext(ctx, "deleted invite successfully", slog.String("invite_id", inviteID)) + response.Status(w, http.StatusNoContent) +} + +// GetInvites handles fetching the list of a users invitations +// +// @Summary Get invitations route +// @Description Gets a list of all invitations the user has created +// @Tags Invite +// @Accept json +// @Produce json +// @Success 200 {object} []domain.Invite +// @Error 500 {object} response.ErrorResponse "failed to get invites" +// @Router /user/invites [get] +func (h *InviteHandler) GetInvites(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + authContext, err := domain.GetAuthContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites") + return + } + + invites, err := h.InviteService.GetInvites(ctx, authContext.UserID) + if err != nil { + slog.ErrorContext(ctx, "failed to get invites", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites") + return + } + + response.JSON(ctx, w, http.StatusOK, invites) +} diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 740f431..13f3445 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -54,7 +54,7 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { return } - slog.InfoContext(ctx, "created list successfully", slog.Any("list_id", list.ID)) + slog.InfoContext(ctx, "created list successfully", slog.String("list_id", list.ID)) response.JSON(ctx, w, http.StatusCreated, list) } @@ -94,8 +94,8 @@ func (h *ListHandler) DeleteList(w http.ResponseWriter, r *http.Request) { return } - slog.InfoContext(ctx, "deleted list successfully", slog.Any("list_id", listID)) - response.Status(ctx, w, http.StatusNoContent) + slog.InfoContext(ctx, "deleted list successfully", slog.String("list_id", listID)) + response.Status(w, http.StatusNoContent) } // GetLists handles fetching lists for the current user @@ -174,8 +174,11 @@ func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) { return } - slog.InfoContext(ctx, "added user to list successfully", slog.Any("list_id", payload.ListID), slog.Any("email", user.Email)) - response.Status(ctx, w, http.StatusNoContent) + slog.InfoContext(ctx, "added user to list successfully", + slog.String("list_id", payload.ListID), + slog.String("email", user.Email), + ) + response.Status(w, http.StatusNoContent) } // RemoveUserFromList handles the removal of a user association to a list @@ -222,8 +225,11 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) return } - slog.InfoContext(ctx, "removed user from list successfully", slog.Any("list_id", payload.ListID), slog.Any("email", user.Email)) - response.Status(ctx, w, http.StatusNoContent) + slog.InfoContext(ctx, "removed user from list successfully", + slog.String("list_id", payload.ListID), + slog.String("email", user.Email), + ) + response.Status(w, http.StatusNoContent) } // CreateListItem handles creation of a new list item on a given list @@ -262,7 +268,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) { return } - slog.InfoContext(ctx, "created list item successfully", slog.Any("list_item_id", item.ID)) + slog.InfoContext(ctx, "created list item successfully", slog.String("list_item_id", item.ID)) response.JSON(ctx, w, http.StatusCreated, item) } @@ -302,8 +308,8 @@ func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) { return } - slog.InfoContext(ctx, "deleted list item successfully", slog.Any("list_item_id", listItemID)) - response.Status(ctx, w, http.StatusNoContent) + slog.InfoContext(ctx, "deleted list item successfully", slog.String("list_item_id", listItemID)) + response.Status(w, http.StatusNoContent) } // UpdateListItem handles updating of a list item @@ -343,8 +349,8 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) { return } - slog.InfoContext(ctx, "updated list item successfully", slog.Any("list_item_id", payload.ListItemID)) - response.Status(ctx, w, http.StatusNoContent) + slog.InfoContext(ctx, "updated list item successfully", slog.String("list_item_id", payload.ListItemID)) + response.Status(w, http.StatusNoContent) } // SetListItemCompleted handles updating "is_completed" of a list item @@ -393,8 +399,8 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques return } - slog.InfoContext(ctx, "updated list item completed successful", slog.Any("list_item_id", listItemID)) - response.Status(ctx, w, http.StatusNoContent) + slog.InfoContext(ctx, "updated list item completed successful", slog.String("list_item_id", listItemID)) + response.Status(w, http.StatusNoContent) } // GetListItems handles return all list items of a list diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go index 8b49bb2..d3be142 100644 --- a/internal/api/handler/user.go +++ b/internal/api/handler/user.go @@ -10,12 +10,13 @@ import ( ) type UserHandler struct { - UserService *domain.UserService - AuthService *domain.AuthService + UserService *domain.UserService + AuthService *domain.AuthService + InviteService *domain.InviteService } -func NewUserHandler(userService *domain.UserService, authService *domain.AuthService) *UserHandler { - return &UserHandler{UserService: userService, AuthService: authService} +func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, inviteService *domain.InviteService) *UserHandler { + return &UserHandler{UserService: userService, AuthService: authService, InviteService: inviteService} } // CreateUser handles the creation of a user @@ -28,6 +29,7 @@ func NewUserHandler(userService *domain.UserService, authService *domain.AuthSer // @Param payload body request.CreateUserPayload true "Create user payload" // @Success 201 {object} domain.User // @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 400 {object} response.ErrorResponse "invite is invalid" // @Error 500 {object} response.ErrorResponse "failed to create user" // @Router /users [post] func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { @@ -40,6 +42,15 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { return } + invite, err := h.InviteService.GetInvite(ctx, payload.InviteCode) + if err != nil { + slog.ErrorContext(ctx, "failed to get invite", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "invite is invalid") + return + } + + // TODO: Creating user and consuming the invite must be in a transaction. + // The repositories must support tx in context, and the handler must be able to start a transaction user, err := h.UserService.CreateUser(ctx, payload.Email, payload.Name, payload.Password) if err != nil { slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err)) @@ -47,7 +58,26 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { return } - slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID)) + err = h.InviteService.ConsumeInvite(ctx, invite.ID, user.ID) + if err != nil { + slog.ErrorContext(ctx, "failed to consume invite", slog.Any("error", err)) + + // TODO: This should be a transaction rollback, once we have db transactions in the handler + err = h.UserService.DeleteUser(ctx, user.ID) + if err != nil { + slog.ErrorContext(ctx, "failed to delete user again", + slog.Any("error", err), + slog.String("user_id", user.ID), + ) + } + response.Error(ctx, w, http.StatusInternalServerError, "failed to consume invite") + return + } + + slog.InfoContext(ctx, "created user successfully", + slog.String("user_id", user.ID), + slog.String("invite_id", invite.ID), + ) response.JSON(ctx, w, http.StatusCreated, user) } @@ -64,7 +94,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { // @Error 401 {object} response.ErrorResponse "could not authenticate with current password" // @Error 500 {object} response.ErrorResponse "could not get auth context" // @Error 500 {object} response.ErrorResponse "failed to change password" -// @Router /users/password [post] +// @Router /user/password [post] func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -102,5 +132,5 @@ func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { } slog.InfoContext(ctx, "password changed successfully", slog.Any("user_id", authContext.UserID)) - response.Status(ctx, w, http.StatusNoContent) + response.Status(w, http.StatusNoContent) } diff --git a/internal/api/request/user.go b/internal/api/request/user.go index fef44d6..fa794ec 100644 --- a/internal/api/request/user.go +++ b/internal/api/request/user.go @@ -1,9 +1,10 @@ package request type CreateUserPayload struct { - Email string `json:"email"` - Name string `json:"name"` - Password string `json:"password"` + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password"` + InviteCode string `json:"invite_code"` } type ChangePasswordPayload struct { diff --git a/internal/api/response/header_only.go b/internal/api/response/header_only.go new file mode 100644 index 0000000..ef31e58 --- /dev/null +++ b/internal/api/response/header_only.go @@ -0,0 +1,9 @@ +package response + +import ( + "net/http" +) + +func Status(w http.ResponseWriter, status int) { + w.WriteHeader(status) +} diff --git a/internal/api/response/json.go b/internal/api/response/json.go index a189d4d..9e69078 100644 --- a/internal/api/response/json.go +++ b/internal/api/response/json.go @@ -2,16 +2,11 @@ package response import ( "context" - "encoding/json" + "encoding/json/v2" "log/slog" "net/http" ) -// TODO: Move to own file -func Status(ctx context.Context, w http.ResponseWriter, status int) { - w.WriteHeader(status) -} - func JSON(ctx context.Context, w http.ResponseWriter, status int, data any) { payload, err := json.Marshal(data) if err != nil { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index a23e28f..2f79904 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -20,9 +20,13 @@ func NewMux(cfg Config) http.Handler { authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret)) authHandler := handler.NewAuthHandler(authService) + inviteRepo := repository.NewInviteRepo(cfg.Database) + inviteService := domain.NewInviteService(inviteRepo) + inviteHandler := handler.NewInviteHandler(inviteService) + userRepo := repository.NewUserRepo(cfg.Database) userService := domain.NewUserService(userRepo) - userHandler := handler.NewUserHandler(userService, authService) + userHandler := handler.NewUserHandler(userService, authService, inviteService) listRepo := repository.NewListRepo(cfg.Database) listService := domain.NewListService(listRepo) @@ -31,17 +35,26 @@ func NewMux(cfg Config) http.Handler { protected := middleware.WithJWT(authService) apiMux := http.NewServeMux() - apiMux.HandleFunc("/", handler.DefaultHandler) apiMux.HandleFunc("GET /health", handler.HealthHandler) + // Auth apiMux.HandleFunc("POST /login", authHandler.Login) apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh) apiMux.HandleFunc("POST /logout", authHandler.Logout) apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices) - apiMux.Handle("POST /users", protected(userHandler.CreateUser)) - apiMux.Handle("POST /users/password", protected(userHandler.ChangePassword)) + // Users + apiMux.HandleFunc("POST /users", userHandler.CreateUser) + // User + apiMux.Handle("POST /user/password", protected(userHandler.ChangePassword)) + + // Invites + apiMux.Handle("POST /user/invites", protected(inviteHandler.CreateInvite)) + apiMux.Handle("DELETE /user/invites/{id}", protected(inviteHandler.DeleteInvite)) + apiMux.Handle("GET /user/invites", protected(inviteHandler.GetInvites)) + + // Lists apiMux.Handle("POST /lists", protected(listHandler.CreateList)) apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList)) apiMux.Handle("GET /lists", protected(listHandler.GetLists)) diff --git a/internal/database/migrations/000003_create_invites.up.sql b/internal/database/migrations/000003_create_invites.up.sql index daee2aa..eb10118 100644 --- a/internal/database/migrations/000003_create_invites.up.sql +++ b/internal/database/migrations/000003_create_invites.up.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS invites ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), inviter_user_id UUID REFERENCES users(id) ON DELETE SET NULL, invitee_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - code VARCHAR(128) NOT NULL, + code VARCHAR(64) NOT NULL, consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() diff --git a/internal/domain/invite.go b/internal/domain/invite.go new file mode 100644 index 0000000..3ca7076 --- /dev/null +++ b/internal/domain/invite.go @@ -0,0 +1,98 @@ +package domain + +import ( + "context" + "errors" + "time" +) + +var ( + ErrInviteIDMissing = errors.New("invite id is required") + ErrCodeMissing = errors.New("invite code is required") + ErrInviteExpired = errors.New("invite is expired") + ErrInviteConsumed = errors.New("invite is already consumed") +) + +type Invite struct { + ID string `json:"id"` + Code string `json:"code"` + ExpiresAt time.Time `json:"expires_at"` + ConsumedAt *time.Time `json:"consumed_at"` +} + +type InviteRepository interface { + CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*Invite, error) + DeleteInvite(ctx context.Context, userID string, inviteID string) error + ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error + GetInvite(ctx context.Context, code string) (*Invite, error) + GetInvites(ctx context.Context, userID string) ([]Invite, error) +} + +type InviteService struct { + repo InviteRepository +} + +func NewInviteService(r InviteRepository) *InviteService { + return &InviteService{repo: r} +} + +func (s *InviteService) CreateInvite(ctx context.Context, inviterUserID string) (*Invite, error) { + if inviterUserID == "" { + return nil, ErrUserIDMissing + } + + token, err := generateSecureToken(32) + if err != nil { + return nil, err + } + code := hashToken(token) + expiresAt := time.Now().Add(time.Hour * 24 * 7) + + return s.repo.CreateInvite(ctx, inviterUserID, code, expiresAt) +} + +func (s *InviteService) DeleteInvite(ctx context.Context, userID string, inviteID string) error { + if userID == "" { + return ErrUserIDMissing + } + if inviteID == "" { + return ErrInviteIDMissing + } + + return s.repo.DeleteInvite(ctx, userID, inviteID) +} + +func (s *InviteService) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error { + if inviteeUserID == "" { + return ErrUserIDMissing + } + + return s.repo.ConsumeInvite(ctx, inviteID, inviteeUserID) +} + +func (s *InviteService) GetInvite(ctx context.Context, code string) (*Invite, error) { + if code == "" { + return nil, ErrCodeMissing + } + + invite, err := s.repo.GetInvite(ctx, code) + if err != nil { + return nil, err + } + if invite.ExpiresAt.Before(time.Now()) { + return nil, ErrInviteExpired + } + if invite.ConsumedAt != nil { + return nil, ErrInviteConsumed + } + + return invite, nil +} + +func (s *InviteService) GetInvites(ctx context.Context, userID string) ([]Invite, error) { + if userID == "" { + return nil, ErrUserIDMissing + } + + return s.repo.GetInvites(ctx, userID) +} diff --git a/internal/domain/user.go b/internal/domain/user.go index d859dae..852a565 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -9,7 +9,7 @@ import ( ) var ( - ErrUserIDMissing = errors.New("user id is missing") + ErrUserIDMissing = errors.New("user id is required") ErrEmailMissing = errors.New("email is required") ErrNameMissing = errors.New("name is required") ErrPasswordMissing = errors.New("password is required") @@ -24,6 +24,7 @@ type User struct { type UserRepository interface { CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error) + DeleteUser(ctx context.Context, userID string) error ChangePassword(ctx context.Context, userID string, passwordHash string) error GetUserByEmail(ctx context.Context, email string) (*User, error) } @@ -55,6 +56,14 @@ func (s *UserService) CreateUser(ctx context.Context, email string, name string, return s.repo.CreateUser(ctx, email, name, string(hash)) } +func (s *UserService) DeleteUser(ctx context.Context, userID string) error { + if len(userID) == 0 { + return ErrUserIDMissing + } + + return s.repo.DeleteUser(ctx, userID) +} + func (s *UserService) ChangePassword(ctx context.Context, userID string, password string) error { if len(userID) == 0 { return ErrUserIDMissing diff --git a/internal/repository/invite.go b/internal/repository/invite.go new file mode 100644 index 0000000..0df87f4 --- /dev/null +++ b/internal/repository/invite.go @@ -0,0 +1,109 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type InviteRepo struct { + db *sql.DB +} + +func NewInviteRepo(db *sql.DB) *InviteRepo { + return &InviteRepo{db: db} +} + +func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*domain.Invite, error) { + var id string + err := r.db.QueryRowContext(ctx, + "INSERT INTO invites (inviter_user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id", + inviterUserID, code, expiresAt, + ).Scan(&id) + if err != nil { + return nil, fmt.Errorf("failed to insert invites: %w", err) + } + + return &domain.Invite{ + ID: id, + Code: code, + ExpiresAt: expiresAt, + ConsumedAt: nil, + }, nil +} + +func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID string) error { + _, err := r.db.ExecContext(ctx, + "DELETE FROM invites WHERE id = $1 AND inviter_user_id = $2 AND consumed_at IS NULL", + inviteID, userID, + ) + if err != nil { + return fmt.Errorf("failed to delete invite: %w", err) + } + + return nil +} + +func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error { + res, err := r.db.ExecContext(ctx, + "UPDATE invites SET invitee_user_id=$1, consumed_at=NOW() WHERE id=$2 AND expires_at > NOW() AND consumed_at IS NULL", + inviteeUserID, inviteID, + ) + if err != nil { + return fmt.Errorf("failed to update invite: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("could not get rows affected: %w", err) + } + if affected < 1 { + return fmt.Errorf("invite not found or expired") + } + + return nil +} + +func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite, error) { + var invite domain.Invite + + err := r.db.QueryRowContext(ctx, + "SELECT id, code, expires_at, consumed_at FROM invites WHERE code=$1", + code, + ).Scan(&invite.ID, &invite.Code, &invite.ExpiresAt, &invite.ConsumedAt) + if err != nil { + return nil, fmt.Errorf("failed to get invite: %w", err) + } + + return &invite, nil +} + +func (r *InviteRepo) GetInvites(ctx context.Context, userID string) ([]domain.Invite, error) { + rows, err := r.db.QueryContext(ctx, + "SELECT id, code, expires_at, consumed_at FROM invites WHERE inviter_user_id=$1", + userID, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get invites: %w", err) + } + defer rows.Close() + + var invites []domain.Invite + for rows.Next() { + var i domain.Invite + err = rows.Scan(&i.ID, &i.Code, &i.ExpiresAt, &i.ConsumedAt) + if err != nil { + return nil, err + } + + invites = append(invites, i) + } + + return invites, nil +} diff --git a/internal/repository/list.go b/internal/repository/list.go index 1a0d470..d58d90a 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -93,7 +93,6 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, } return lists, nil - } func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error { diff --git a/internal/repository/user.go b/internal/repository/user.go index eff6efe..e385fb1 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -40,6 +40,18 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa return user, nil } +func (r *UserRepo) DeleteUser(ctx context.Context, userID string) error { + _, err := r.db.ExecContext(ctx, + "DELETE FROM users WHERE id = $1", + userID, + ) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + return nil +} + func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHash string) error { _, err := r.db.ExecContext(ctx, "UPDATE users SET password_hash = $1 WHERE id = $2",