Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa1eaf23b | ||
|
|
d9678ebb3b | ||
|
|
e9a96f1ff9 | ||
|
|
61b91c473b | ||
|
|
7f5b0fee11 | ||
|
|
3f6b7b6636 | ||
|
|
cf3a49458a | ||
|
|
ac95a857bd | ||
|
|
b895ceaccf | ||
|
|
59b3687e43 | ||
|
|
0a348593b1 | ||
|
|
72f4e6527c | ||
|
|
d3ad443fde | ||
|
|
0969887008 | ||
|
|
05b61cef09 | ||
|
|
430747536d | ||
|
|
aecdd1030a | ||
|
|
97422c1ef5 |
+33
-11
@@ -9,19 +9,33 @@ MONGO_DB=spsays
|
|||||||
# PostgreSQL (users, relations)
|
# PostgreSQL (users, relations)
|
||||||
POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable
|
POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable
|
||||||
|
|
||||||
# Meilisearch
|
# Typesense
|
||||||
MEILISEARCH_HOST=http://localhost:7700
|
TYPESENSE_URL=https://search.prabhupadasays.org
|
||||||
MEILISEARCH_API_KEY=
|
TYPESENSE_API_KEY=
|
||||||
|
|
||||||
# DragonflyDB / Redis
|
# DragonflyDB / Redis
|
||||||
REDIS_URI=redis://localhost:6379
|
REDIS_URI=redis://localhost:6379
|
||||||
|
|
||||||
# S3
|
# Cloudflare R2 (avatar uploads via presigned URLs)
|
||||||
S3_REGION=
|
# R2 endpoint is auto-built as https://<account_id>.r2.cloudflarestorage.com
|
||||||
S3_BUCKET=
|
R2_ACCOUNT_ID=
|
||||||
S3_ACCESS_KEY=
|
R2_ACCESS_KEY_ID=
|
||||||
S3_SECRET_KEY=
|
R2_SECRET_ACCESS_KEY=
|
||||||
S3_ENDPOINT=
|
R2_BUCKET=
|
||||||
|
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
|
||||||
|
R2_PUBLIC_URL=
|
||||||
|
|
||||||
|
# SMTP (password-reset + email-verification + /feedback admin notifications)
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>"
|
||||||
|
|
||||||
|
# Comma-separated recipients for /feedback admin notifications. Empty
|
||||||
|
# disables the email fan-out (feedback rows still persist and show on
|
||||||
|
# the /stats page either way).
|
||||||
|
FEEDBACK_ADMIN_EMAILS=
|
||||||
|
|
||||||
# Push Notifications
|
# Push Notifications
|
||||||
FCM_CREDENTIALS_FILE=
|
FCM_CREDENTIALS_FILE=
|
||||||
@@ -32,7 +46,15 @@ APNS_BUNDLE_ID=
|
|||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET=change-me-in-production
|
JWT_SECRET=change-me-in-production
|
||||||
JWT_EXPIRATION=24h
|
JWT_EXPIRATION=168h
|
||||||
|
# Cookie domain — empty in dev (uses request host), ".prabhupadasays.org" in prod
|
||||||
|
COOKIE_DOMAIN=
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
# Frontend
|
# Frontend (CORS + email link base)
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
GOOGLE_OAUTH_CLIENT_ID=
|
||||||
|
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||||
|
FACEBOOK_OAUTH_CLIENT_ID=
|
||||||
|
FACEBOOK_OAUTH_CLIENT_SECRET=
|
||||||
@@ -3,18 +3,34 @@ module github.com/prabhupadasays/spsays-bff
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/redis/go-redis/v9 v9.19.0
|
github.com/redis/go-redis/v9 v9.19.0
|
||||||
github.com/typesense/typesense-go/v2 v2.0.0
|
github.com/typesense/typesense-go/v2 v2.0.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.2.1
|
go.mongodb.org/mongo-driver/v2 v2.2.1
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.52.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
|
||||||
|
github.com/aws/smithy-go v1.26.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/google/uuid v1.5.0 // indirect
|
github.com/google/uuid v1.5.0 // indirect
|
||||||
@@ -30,7 +46,6 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||||
|
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||||
|
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
@@ -15,6 +41,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
|||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
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/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
@@ -79,31 +107,33 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
|||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
+101
-19
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
@@ -28,33 +30,64 @@ type Config struct {
|
|||||||
// DragonflyDB / Redis
|
// DragonflyDB / Redis
|
||||||
RedisURI string
|
RedisURI string
|
||||||
|
|
||||||
// AWS S3
|
// Cloudflare R2 (S3-compatible) — for avatar uploads via presigned URLs.
|
||||||
S3Region string
|
R2AccountID string
|
||||||
S3Bucket string
|
R2AccessKeyID string
|
||||||
S3AccessKey string
|
R2SecretAccessKey string
|
||||||
S3SecretKey string
|
R2Bucket string
|
||||||
S3Endpoint string // optional — for S3-compatible services (MinIO, etc.)
|
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
|
||||||
|
|
||||||
// Push Notifications
|
// SMTP — used for password-reset, email-verification, and
|
||||||
FCMCredentialsFile string // path to Firebase service account JSON
|
// /feedback admin-notification mails.
|
||||||
APNsKeyFile string // path to APNs auth key
|
SMTPHost string
|
||||||
|
SMTPPort int
|
||||||
|
SMTPUsername string
|
||||||
|
SMTPPassword string
|
||||||
|
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
|
||||||
|
|
||||||
|
// Comma-separated FEEDBACK_ADMIN_EMAILS — recipients for the
|
||||||
|
// /feedback notification fan-out. Empty disables the email send
|
||||||
|
// (the DB row is still written and visible via /stats).
|
||||||
|
FeedbackAdminEmails []string
|
||||||
|
|
||||||
|
// Push Notifications (existing — unchanged).
|
||||||
|
FCMCredentialsFile string
|
||||||
|
APNsKeyFile string
|
||||||
APNsKeyID string
|
APNsKeyID string
|
||||||
APNsTeamID string
|
APNsTeamID string
|
||||||
APNsBundleID string
|
APNsBundleID string
|
||||||
|
|
||||||
// JWT Auth
|
// JWT Auth
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
JWTExpiration string // e.g., "24h"
|
JWTExpiration time.Duration // parsed at load time
|
||||||
|
CookieDomain string // empty in dev; ".prabhupadasays.org" in prod
|
||||||
|
CookieSecure bool // true in production
|
||||||
|
|
||||||
// Frontend
|
// Frontend
|
||||||
FrontendURL string // for CORS
|
FrontendURL string // for CORS + reset/verify links in emails
|
||||||
|
|
||||||
|
// BFF public URL — the externally-reachable origin of this server. Used
|
||||||
|
// to build the OAuth redirect_uri the provider sees (must match the
|
||||||
|
// allow-list configured in the provider's console). In dev this is
|
||||||
|
// usually http://localhost:8080.
|
||||||
|
BFFPublicURL string
|
||||||
|
|
||||||
|
// OAuth providers — empty creds disable the corresponding provider.
|
||||||
|
GoogleOAuthClientID string
|
||||||
|
GoogleOAuthClientSecret string
|
||||||
|
FacebookOAuthClientID string
|
||||||
|
FacebookOAuthClientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables (with .env fallback).
|
// Load reads configuration from environment variables (with .env fallback).
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
// Load .env file if present (ignored in production)
|
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
jwtExp, err := time.ParseDuration(getEnv("JWT_EXPIRATION", "168h"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Env: getEnv("APP_ENV", "development"),
|
Env: getEnv("APP_ENV", "development"),
|
||||||
Port: getEnvInt("PORT", 8080),
|
Port: getEnvInt("PORT", 8080),
|
||||||
@@ -69,11 +102,19 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
|
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
|
||||||
|
|
||||||
S3Region: getEnv("S3_REGION", ""),
|
R2AccountID: getEnv("R2_ACCOUNT_ID", ""),
|
||||||
S3Bucket: getEnv("S3_BUCKET", ""),
|
R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""),
|
||||||
S3AccessKey: getEnv("S3_ACCESS_KEY", ""),
|
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
|
||||||
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
|
R2Bucket: getEnv("R2_BUCKET", ""),
|
||||||
S3Endpoint: getEnv("S3_ENDPOINT", ""),
|
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
|
||||||
|
|
||||||
|
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||||
|
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
||||||
|
SMTPUsername: getEnv("SMTP_USERNAME", ""),
|
||||||
|
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||||
|
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
||||||
|
|
||||||
|
FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")),
|
||||||
|
|
||||||
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
|
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
|
||||||
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
|
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
|
||||||
@@ -82,15 +123,30 @@ func Load() (*Config, error) {
|
|||||||
APNsBundleID: getEnv("APNS_BUNDLE_ID", ""),
|
APNsBundleID: getEnv("APNS_BUNDLE_ID", ""),
|
||||||
|
|
||||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||||
JWTExpiration: getEnv("JWT_EXPIRATION", "24h"),
|
JWTExpiration: jwtExp,
|
||||||
|
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
|
||||||
|
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||||
|
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
|
|
||||||
|
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
||||||
|
|
||||||
|
GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""),
|
||||||
|
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
|
||||||
|
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
|
||||||
|
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if cfg.MongoURI == "" {
|
if cfg.MongoURI == "" {
|
||||||
return nil, fmt.Errorf("MONGO_URI is required")
|
return nil, fmt.Errorf("MONGO_URI is required")
|
||||||
}
|
}
|
||||||
|
if cfg.Env == "production" && cfg.JWTSecret == "" {
|
||||||
|
return nil, fmt.Errorf("JWT_SECRET is required in production")
|
||||||
|
}
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
// Dev fallback so the server can boot without secrets configured.
|
||||||
|
cfg.JWTSecret = "dev-only-jwt-secret-do-not-use-in-production"
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
@@ -110,3 +166,29 @@ func getEnvInt(key string, fallback int) int {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvBool(key string, fallback bool) bool {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseList splits a comma-separated env value, trimming whitespace
|
||||||
|
// and discarding empty entries. Returns nil for empty input so
|
||||||
|
// callers can range over it safely without a length check.
|
||||||
|
func parseList(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/geoip"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandler exposes the auth-related HTTP endpoints.
|
||||||
|
//
|
||||||
|
// Every handler is a 5-line shim: parse → validate → service → render.
|
||||||
|
// All branching (already exists? wrong password? expired link?) lives in
|
||||||
|
// AuthService and surfaces here only as sentinel errors mapped to status
|
||||||
|
// codes via mapAuthErr.
|
||||||
|
type AuthHandler struct {
|
||||||
|
svc *service.AuthService
|
||||||
|
cookies auth.CookieOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler {
|
||||||
|
return &AuthHandler{svc: svc, cookies: cookies}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- request shapes ---
|
||||||
|
|
||||||
|
type signUpReq struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type logInReq struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type forgotReq struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resetReq struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type verifyReq struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type verifyLoginOTPReq struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
OTP string `json:"otp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type setEmailReq struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type changePasswordReq struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- handlers ---
|
||||||
|
|
||||||
|
// SignUp creates a user, plants the session cookie, returns the user.
|
||||||
|
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req signUpReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "email", req.Email)
|
||||||
|
validator.Email(v, "email", req.Email)
|
||||||
|
validator.Required(v, "password", req.Password)
|
||||||
|
validator.MinLength(v, "password", req.Password, 8)
|
||||||
|
validator.Required(v, "name", req.Name)
|
||||||
|
validator.MaxLength(v, "name", req.Name, 120)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.captureCountry(r, res.User.ID)
|
||||||
|
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||||
|
response.JSON(w, http.StatusCreated, res.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureCountry records the new account's country off the critical path.
|
||||||
|
// Cloudflare's CF-IPCountry header is preferred (no network); when absent
|
||||||
|
// — as in the current DNS-only setup — we fall back to a GeoIP lookup on
|
||||||
|
// the client IP. The header read + IP extraction happen synchronously
|
||||||
|
// (they need the live request), then the network lookup + DB write run in
|
||||||
|
// a goroutine so signup latency is untouched. Any failure is silent: the
|
||||||
|
// account simply keeps country = NULL until the user sets it themselves.
|
||||||
|
func (h *AuthHandler) captureCountry(r *http.Request, userID string) {
|
||||||
|
cfCountry := geoip.CountryFromCloudflare(r)
|
||||||
|
ip := geoip.ClientIP(r)
|
||||||
|
go func() {
|
||||||
|
cc := cfCountry
|
||||||
|
if cc == "" {
|
||||||
|
lookupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
cc = geoip.LookupCountryByIP(lookupCtx, ip)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if cc == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
h.svc.CaptureCountry(writeCtx, userID, cc)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogIn authenticates an existing user. The response is one of:
|
||||||
|
//
|
||||||
|
// - 200 OK with the User JSON — credentials verified, session cookie set,
|
||||||
|
// frontend treats this as fully signed in.
|
||||||
|
// - 202 Accepted with `{ "otp_required": true }` — credentials verified
|
||||||
|
// but the account has been unverified for over 30 days. An OTP has been
|
||||||
|
// emailed; frontend must collect it and call VerifyLoginOTP next. No
|
||||||
|
// session cookie is set on this branch.
|
||||||
|
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req logInReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "email", req.Email)
|
||||||
|
validator.Required(v, "password", req.Password)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.LogIn(r.Context(), req.Email, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.OTPRequired {
|
||||||
|
response.JSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"otp_required": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies)
|
||||||
|
response.JSON(w, http.StatusOK, res.Auth.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyLoginOTP completes a login that was gated by the stale-unverified
|
||||||
|
// OTP path. Re-checks the password (the OTP alone isn't enough — an
|
||||||
|
// attacker who steals the OTP email still needs the password), validates
|
||||||
|
// the OTP, flips email_verified_at to now(), and sets the session cookie.
|
||||||
|
func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req verifyLoginOTPReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "email", req.Email)
|
||||||
|
validator.Required(v, "password", req.Password)
|
||||||
|
validator.Required(v, "otp", req.OTP)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP)
|
||||||
|
if err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||||
|
response.JSON(w, http.StatusOK, res.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogOut just clears the cookie. JWTs remain technically valid until expiry,
|
||||||
|
// but with the cookie gone the browser can't present them.
|
||||||
|
func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.Clear(w, h.cookies)
|
||||||
|
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me returns the authenticated user. Protected by middleware.RequireAuth.
|
||||||
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
tv := middleware.TokenVersion(r.Context())
|
||||||
|
u, err := h.svc.Me(r.Context(), uid, tv)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForgotPassword emails a reset link if the address exists. We always return
|
||||||
|
// 200 to avoid revealing whether the email is registered.
|
||||||
|
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req forgotReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "email", req.Email)
|
||||||
|
validator.Email(v, "email", req.Email)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not process request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword consumes a reset token and sets a new password.
|
||||||
|
func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req resetReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "token", req.Token)
|
||||||
|
validator.Required(v, "new_password", req.NewPassword)
|
||||||
|
validator.MinLength(v, "new_password", req.NewPassword, 8)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Force the user to log in again after a reset (their token_version is now stale).
|
||||||
|
auth.Clear(w, h.cookies)
|
||||||
|
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword — protected — replaces the caller's password after
|
||||||
|
// verifying the current one. The new password bumps token_version, which
|
||||||
|
// invalidates every previously-issued JWT for this user. We immediately
|
||||||
|
// re-issue a fresh session cookie so the caller's current device stays
|
||||||
|
// logged in; every other device gets signed out on next request.
|
||||||
|
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
|
||||||
|
var req changePasswordReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// current_password is optional — accounts with no password on file
|
||||||
|
// (OAuth-only signups setting their first one) skip it. The service
|
||||||
|
// enforces verification when a hash exists.
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "new_password", req.NewPassword)
|
||||||
|
validator.MinLength(v, "new_password", req.NewPassword, 8)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.ChangePassword(r.Context(), uid, req.CurrentPassword, req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||||
|
response.JSON(w, http.StatusOK, res.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail flips email_verified_at after the user clicks the link.
|
||||||
|
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req verifyReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Token == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "token is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEmail — protected — sets (or replaces) the caller's email address.
|
||||||
|
// Used by OAuth-signup users who landed without one, and by anyone wanting
|
||||||
|
// to change their address. The new value is stored as unverified and a
|
||||||
|
// verification link is mailed out immediately.
|
||||||
|
func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
|
||||||
|
var req setEmailReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "email", req.Email)
|
||||||
|
validator.Email(v, "email", req.Email)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.svc.SetEmail(r.Context(), uid, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedProviders — protected — returns the OAuth provider names already
|
||||||
|
// linked to the caller's account. Settings UI uses this to flip each
|
||||||
|
// social-connect row between "Connect" and "Connected".
|
||||||
|
func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
providers, err := h.svc.LinkedProviders(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load linked providers")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"providers": providers})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendVerification — protected — refires the verification email for the
|
||||||
|
// currently-logged-in user.
|
||||||
|
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if err := h.svc.ResendVerification(r.Context(), uid); err != nil {
|
||||||
|
h.renderAuthErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- error mapping ---
|
||||||
|
|
||||||
|
func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrInvalidCredentials),
|
||||||
|
errors.Is(err, service.ErrInvalidOrExpiredLink):
|
||||||
|
response.Error(w, http.StatusUnauthorized, err.Error())
|
||||||
|
case errors.Is(err, service.ErrEmailAlreadyTaken):
|
||||||
|
response.Error(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, service.ErrEmailAlreadyVerified),
|
||||||
|
errors.Is(err, service.ErrWeakPassword):
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
case errors.Is(err, service.ErrUserNotFound):
|
||||||
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedbackHandler exposes POST /api/v1/feedback. The router wraps it
|
||||||
|
// with OptionalAuth so a session cookie populates user_id when one is
|
||||||
|
// present, and anonymous callers are still accepted (they have to
|
||||||
|
// supply contact_email instead).
|
||||||
|
type FeedbackHandler struct{ svc *service.FeedbackService }
|
||||||
|
|
||||||
|
func NewFeedbackHandler(svc *service.FeedbackService) *FeedbackHandler {
|
||||||
|
return &FeedbackHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type submitFeedbackReq struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ContactEmail *string `json:"contact_email,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req submitFeedbackReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "category", req.Category)
|
||||||
|
validator.Required(v, "message", req.Message)
|
||||||
|
validator.MaxLength(v, "message", req.Message, 5000)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalAuth seeds user_id when a valid session cookie was
|
||||||
|
// present; "" means anonymous.
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
var userIDPtr *string
|
||||||
|
if uid != "" {
|
||||||
|
userIDPtr = &uid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-validate the contact email for the anonymous path. Authed
|
||||||
|
// callers' contact_email is ignored downstream, so skip the check.
|
||||||
|
if userIDPtr == nil && req.ContactEmail != nil && *req.ContactEmail != "" {
|
||||||
|
ev := validator.New()
|
||||||
|
validator.Email(ev, "contact_email", *req.ContactEmail)
|
||||||
|
if !ev.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, ev.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := h.svc.Submit(r.Context(), userIDPtr, req.Category, req.Message, req.ContactEmail)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationHandler serves the signed-in user's notification feed +
|
||||||
|
// unread badge count, and marks them read. All routes sit behind
|
||||||
|
// RequireAuth, so middleware.UserID is always populated.
|
||||||
|
type NotificationHandler struct{ svc *service.NotificationService }
|
||||||
|
|
||||||
|
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
|
||||||
|
return &NotificationHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the caller's notifications, newest first.
|
||||||
|
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
items, err := h.svc.List(r.Context(), uid, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load notifications")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnreadCount returns {"count": N} for the bell badge.
|
||||||
|
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
n, err := h.svc.UnreadCount(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load unread count")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]int{"count": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllRead marks every notification read (called when the page opens).
|
||||||
|
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if err := h.svc.MarkAllRead(r.Context(), uid); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not mark notifications read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stateCookieName is the short-lived cookie that pairs with the state query
|
||||||
|
// param so callback can verify "this redirect was started by us." Scoped to
|
||||||
|
// /api/v1/auth/oauth so it never leaks into other handlers.
|
||||||
|
const stateCookieName = "spsays_oauth_state"
|
||||||
|
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
|
||||||
|
|
||||||
|
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
|
||||||
|
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
|
||||||
|
type OAuthHandler struct {
|
||||||
|
svc *service.AuthService
|
||||||
|
cookies auth.CookieOptions
|
||||||
|
providers oauth.Registry
|
||||||
|
stateSecret []byte // HMAC key for signing state — derived from JWT secret
|
||||||
|
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
|
||||||
|
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOAuthHandler(
|
||||||
|
svc *service.AuthService,
|
||||||
|
cookies auth.CookieOptions,
|
||||||
|
providers oauth.Registry,
|
||||||
|
stateSecret []byte,
|
||||||
|
bffPublicURL, frontendURL string,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *OAuthHandler {
|
||||||
|
return &OAuthHandler{
|
||||||
|
svc: svc,
|
||||||
|
cookies: cookies,
|
||||||
|
providers: providers,
|
||||||
|
stateSecret: stateSecret,
|
||||||
|
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
|
||||||
|
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start kicks off the OAuth round-trip. It generates a signed state value,
|
||||||
|
// stores it in a short-lived cookie, and 302s the browser to the provider's
|
||||||
|
// AuthCodeURL. `next` query param carries the post-login redirect path on
|
||||||
|
// the frontend (defaults to "/u/me").
|
||||||
|
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerName := chi.URLParam(r, "provider")
|
||||||
|
prov, err := h.providers.Get(providerName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unknown provider", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||||
|
|
||||||
|
state, err := oauth.EncodeState(h.stateSecret, providerName, next, "")
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("oauth encode state", zap.Error(err))
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// State cookie matches the auth cookie's SameSite/Secure profile so the
|
||||||
|
// browser sends it back on the callback redirect. Path-scoped so it
|
||||||
|
// doesn't ride along on unrelated requests.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookieName,
|
||||||
|
Value: state,
|
||||||
|
Path: "/api/v1/auth/oauth",
|
||||||
|
Domain: h.cookies.Domain,
|
||||||
|
MaxAge: stateCookieMaxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cookies.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := prov.Config(h.callbackURL(providerName))
|
||||||
|
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback finishes the round-trip. Verifies state, exchanges the code,
|
||||||
|
// fetches the profile, signs the user in, sets the session cookie, and
|
||||||
|
// redirects back to the frontend `next` path.
|
||||||
|
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerName := chi.URLParam(r, "provider")
|
||||||
|
prov, err := h.providers.Get(providerName)
|
||||||
|
if err != nil {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider returned an error (user denied, etc.).
|
||||||
|
if e := r.URL.Query().Get("error"); e != "" {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// State must be present in BOTH cookie and query param, and they must
|
||||||
|
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
|
||||||
|
// query alone → an attacker could craft any signed state).
|
||||||
|
cookie, err := r.Cookie(stateCookieName)
|
||||||
|
if err != nil {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=missing_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateQ := r.URL.Query().Get("state")
|
||||||
|
if stateQ == "" || stateQ != cookie.Value {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Clear the state cookie regardless of what happens next.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/api/v1/auth/oauth",
|
||||||
|
Domain: h.cookies.Domain,
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cookies.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
stateProvider, next, linkUserID, err := oauth.DecodeState(h.stateSecret, stateQ)
|
||||||
|
if err != nil || stateProvider != providerName {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=missing_code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := prov.Config(h.callbackURL(providerName))
|
||||||
|
tok, err := cfg.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prof, err := prov.FetchProfile(r.Context(), tok)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// State pinned to a user id means the OAuth round-trip was started from
|
||||||
|
// /link/start (settings page). Dispatch to the link path so we link the
|
||||||
|
// provider to that user instead of running a fresh sign-in.
|
||||||
|
if linkUserID != "" {
|
||||||
|
h.finishLink(w, r, providerName, next, linkUserID, prof)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
|
||||||
|
Provider: prof.Provider,
|
||||||
|
ProviderUserID: prof.ProviderUserID,
|
||||||
|
Email: prof.Email,
|
||||||
|
EmailVerified: prof.EmailVerified,
|
||||||
|
Name: prof.Name,
|
||||||
|
AvatarURL: prof.AvatarURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrOAuthUnavailable):
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=unavailable")
|
||||||
|
default:
|
||||||
|
h.logger.Error("oauth sign-in", zap.Error(err))
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=server")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||||
|
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkStart kicks off the OAuth round-trip for an already-authenticated
|
||||||
|
// user who wants to add a provider to their account (settings → Connect).
|
||||||
|
// The caller's user id is signed into the state so the callback knows
|
||||||
|
// which account to link to instead of trying to find one by email.
|
||||||
|
func (h *OAuthHandler) LinkStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerName := chi.URLParam(r, "provider")
|
||||||
|
prov, err := h.providers.Get(providerName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unknown provider", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if uid == "" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||||
|
|
||||||
|
state, err := oauth.EncodeState(h.stateSecret, providerName, next, uid)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("oauth encode link state", zap.Error(err))
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookieName,
|
||||||
|
Value: state,
|
||||||
|
Path: "/api/v1/auth/oauth",
|
||||||
|
Domain: h.cookies.Domain,
|
||||||
|
MaxAge: stateCookieMaxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cookies.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := prov.Config(h.callbackURL(providerName))
|
||||||
|
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishLink completes the link flow once the shared Callback has decoded
|
||||||
|
// state and fetched the provider profile. Bounces back to the frontend
|
||||||
|
// `next` path with `?link=ok|claimed|error` so the settings UI can render
|
||||||
|
// a meaningful message.
|
||||||
|
func (h *OAuthHandler) finishLink(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
providerName, next, linkUserID string,
|
||||||
|
prof *oauth.Profile,
|
||||||
|
) {
|
||||||
|
err := h.svc.LinkOAuthToCurrentUser(r.Context(), linkUserID, service.OAuthProfile{
|
||||||
|
Provider: prof.Provider,
|
||||||
|
ProviderUserID: prof.ProviderUserID,
|
||||||
|
Email: prof.Email,
|
||||||
|
EmailVerified: prof.EmailVerified,
|
||||||
|
Name: prof.Name,
|
||||||
|
AvatarURL: prof.AvatarURL,
|
||||||
|
})
|
||||||
|
target := next
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
target = appendQuery(next, "link", providerName+":ok")
|
||||||
|
case errors.Is(err, service.ErrOAuthAccountClaimed):
|
||||||
|
target = appendQuery(next, "link", providerName+":claimed")
|
||||||
|
default:
|
||||||
|
h.logger.Error("oauth link", zap.String("provider", providerName), zap.Error(err))
|
||||||
|
target = appendQuery(next, "link", providerName+":error")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, h.frontendURL+target, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendQuery adds a ?key=val (or &key=val) to a same-origin path, preserving
|
||||||
|
// any existing query string. Path must start with "/" — caller guarantees.
|
||||||
|
func appendQuery(path, key, value string) string {
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(path, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
return path + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callbackURL composes the redirect_uri the provider's console must allow-list.
|
||||||
|
func (h *OAuthHandler) callbackURL(provider string) string {
|
||||||
|
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectErr sends the user back to the frontend with a single error query
|
||||||
|
// param so the login page can render something meaningful. No internal details
|
||||||
|
// leak through this path.
|
||||||
|
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
|
||||||
|
// containing a scheme, host, or whitespace is dropped to the default. This is
|
||||||
|
// the same anti-open-redirect treatment we apply on the share-shortener.
|
||||||
|
func sanitizeNext(raw string) string {
|
||||||
|
const def = "/u/me"
|
||||||
|
if raw == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(raw, " \t\r\n") {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if len(raw) > 512 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelationshipHandler exposes the follow / block / report actions, all
|
||||||
|
// scoped to a target user identified by {handle} in the path and a
|
||||||
|
// signed-in viewer pulled from the auth middleware. Every mutating
|
||||||
|
// endpoint returns the recomputed RelationshipResult (viewer's state +
|
||||||
|
// the target's live counts) so the frontend updates its buttons and
|
||||||
|
// stat line from the single response — except Report, which returns 204.
|
||||||
|
//
|
||||||
|
// These routes live under /api/v1/u/{handle}/… in a RequireAuth group;
|
||||||
|
// the public profile GET stays anonymous-friendly in its own group.
|
||||||
|
type RelationshipHandler struct{ svc *service.SocialGraphService }
|
||||||
|
|
||||||
|
func NewRelationshipHandler(svc *service.SocialGraphService) *RelationshipHandler {
|
||||||
|
return &RelationshipHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow sends a follow REQUEST (pending until the target accepts).
|
||||||
|
func (h *RelationshipHandler) Follow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.act(w, r, h.svc.RequestFollow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfollow removes the viewer's edge — stops following or cancels a
|
||||||
|
// still-pending request.
|
||||||
|
func (h *RelationshipHandler) Unfollow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.act(w, r, h.svc.Unfollow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptFollow accepts the target's pending request to follow the viewer.
|
||||||
|
func (h *RelationshipHandler) AcceptFollow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.act(w, r, h.svc.AcceptFollow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectFollow declines the target's pending request.
|
||||||
|
func (h *RelationshipHandler) RejectFollow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.act(w, r, h.svc.RejectFollow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFollower drops the target from the viewer's followers.
|
||||||
|
func (h *RelationshipHandler) RemoveFollower(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.act(w, r, h.svc.RemoveFollower)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block blocks the target (and severs follows both ways).
|
||||||
|
func (h *RelationshipHandler) Block(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.act(w, r, h.svc.Block)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock removes the viewer's block on the target.
|
||||||
|
func (h *RelationshipHandler) Unblock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.act(w, r, h.svc.Unblock)
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportReq struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report files a report against the target. Body: {category, reason}.
|
||||||
|
func (h *RelationshipHandler) Report(w http.ResponseWriter, r *http.Request) {
|
||||||
|
viewerID := middleware.UserID(r.Context())
|
||||||
|
handle := chi.URLParam(r, "handle")
|
||||||
|
var req reportReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Report(r.Context(), viewerID, handle, req.Category, req.Reason); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// act is the shared shim for every RelationshipResult-returning action:
|
||||||
|
// pull viewer + handle, run the service method, map errors, return JSON.
|
||||||
|
func (h *RelationshipHandler) act(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
fn func(ctx context.Context, viewerID, handle string) (*service.RelationshipResult, error),
|
||||||
|
) {
|
||||||
|
viewerID := middleware.UserID(r.Context())
|
||||||
|
handle := chi.URLParam(r, "handle")
|
||||||
|
res, err := fn(r.Context(), viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
+44
-10
@@ -147,6 +147,12 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
limit = 100
|
limit = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `exact=1` (or "true") disables typo tolerance + prefix expansion so the
|
||||||
|
// query only matches literal keywords. Stopword filtering still applies —
|
||||||
|
// "letter to mukunda" with exact=1 still drops "letter"/"to" so the search
|
||||||
|
// is effectively the literal word "mukunda" with no fuzzy variants.
|
||||||
|
exact := parseBoolParam(r.URL.Query().Get("exact"))
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
var results []searchResultItem
|
var results []searchResultItem
|
||||||
@@ -154,13 +160,13 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if searchType == "all" {
|
if searchType == "all" {
|
||||||
// Search all 9 collections
|
// Search all 9 collections
|
||||||
results, totalFound = h.multiSearch(ctx, nil, query, page, limit)
|
results, totalFound = h.multiSearch(ctx, nil, query, page, limit, exact)
|
||||||
} else if len(requestedTypes) == 1 {
|
} else if len(requestedTypes) == 1 {
|
||||||
// Single collection — use singleSearch for efficiency
|
// Single collection — use singleSearch for efficiency
|
||||||
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit)
|
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit, exact)
|
||||||
} else {
|
} else {
|
||||||
// Multiple collections — use multiSearch with a subset
|
// Multiple collections — use multiSearch with a subset
|
||||||
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit)
|
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit, exact)
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPages := int(math.Ceil(float64(totalFound) / float64(limit)))
|
totalPages := int(math.Ceil(float64(totalFound) / float64(limit)))
|
||||||
@@ -181,8 +187,10 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
// multiSearch searches across all 9 collections using Typesense multi_search,
|
// multiSearch searches across all 9 collections using Typesense multi_search,
|
||||||
// merges results by text_match score, and returns a single paginated list.
|
// merges results by text_match score, and returns a single paginated list.
|
||||||
// multiSearch searches across multiple collections using Typesense multi_search.
|
// multiSearch searches across multiple collections using Typesense multi_search.
|
||||||
// If collections is nil, searches all 9 collections.
|
// If collections is nil, searches all 9 collections. When `exact` is true,
|
||||||
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int) ([]searchResultItem, int) {
|
// typo tolerance and prefix expansion are turned off so only literal-keyword
|
||||||
|
// matches come back.
|
||||||
|
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int, exact bool) ([]searchResultItem, int) {
|
||||||
if collections == nil {
|
if collections == nil {
|
||||||
collections = []string{
|
collections = []string{
|
||||||
"verses", "prose", "letters", "transcriptions", "glossary",
|
"verses", "prose", "letters", "transcriptions", "glossary",
|
||||||
@@ -193,6 +201,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
|||||||
exhaustive := true
|
exhaustive := true
|
||||||
prioritizeExact := true
|
prioritizeExact := true
|
||||||
prioritizeTokenPos := true
|
prioritizeTokenPos := true
|
||||||
|
prefix, numTypos := fuzzinessKnobs(exact)
|
||||||
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
|
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
queryBy := collectionQueryByFields[col]
|
queryBy := collectionQueryByFields[col]
|
||||||
@@ -205,8 +214,8 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
|||||||
QueryBy: stringPtr(queryBy),
|
QueryBy: stringPtr(queryBy),
|
||||||
PerPage: &perPage,
|
PerPage: &perPage,
|
||||||
Page: &pg,
|
Page: &pg,
|
||||||
Prefix: stringPtr("true"),
|
Prefix: stringPtr(prefix),
|
||||||
NumTypos: stringPtr("2"),
|
NumTypos: stringPtr(numTypos),
|
||||||
ExhaustiveSearch: &exhaustive,
|
ExhaustiveSearch: &exhaustive,
|
||||||
Stopwords: stringPtr(searchStopwordsSet),
|
Stopwords: stringPtr(searchStopwordsSet),
|
||||||
// Boost docs whose tokens are exact matches and appear early in the field
|
// Boost docs whose tokens are exact matches and appear early in the field
|
||||||
@@ -264,7 +273,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
|||||||
}
|
}
|
||||||
|
|
||||||
// singleSearch searches a single collection.
|
// singleSearch searches a single collection.
|
||||||
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int) ([]searchResultItem, int) {
|
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int, exact bool) ([]searchResultItem, int) {
|
||||||
queryBy, ok := collectionQueryByFields[collection]
|
queryBy, ok := collectionQueryByFields[collection]
|
||||||
if !ok {
|
if !ok {
|
||||||
return []searchResultItem{}, 0
|
return []searchResultItem{}, 0
|
||||||
@@ -273,13 +282,14 @@ func (h *SearchHandler) singleSearch(ctx context.Context, collection, query stri
|
|||||||
exhaustive := true
|
exhaustive := true
|
||||||
prioritizeExact := true
|
prioritizeExact := true
|
||||||
prioritizeTokenPos := true
|
prioritizeTokenPos := true
|
||||||
|
prefix, numTypos := fuzzinessKnobs(exact)
|
||||||
params := &api.SearchCollectionParams{
|
params := &api.SearchCollectionParams{
|
||||||
Q: stringPtr(query),
|
Q: stringPtr(query),
|
||||||
QueryBy: stringPtr(queryBy),
|
QueryBy: stringPtr(queryBy),
|
||||||
Page: &page,
|
Page: &page,
|
||||||
PerPage: &limit,
|
PerPage: &limit,
|
||||||
Prefix: stringPtr("true"),
|
Prefix: stringPtr(prefix),
|
||||||
NumTypos: stringPtr("2"),
|
NumTypos: stringPtr(numTypos),
|
||||||
ExhaustiveSearch: &exhaustive,
|
ExhaustiveSearch: &exhaustive,
|
||||||
Stopwords: stringPtr(searchStopwordsSet),
|
Stopwords: stringPtr(searchStopwordsSet),
|
||||||
// Boost docs whose tokens are exact matches and appear early in the field
|
// Boost docs whose tokens are exact matches and appear early in the field
|
||||||
@@ -534,3 +544,27 @@ func parseIntParam(s string, defaultVal int) int {
|
|||||||
func stringPtr(s string) *string {
|
func stringPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseBoolParam returns true for "1", "true", "yes", "on" (case-insensitive).
|
||||||
|
// Everything else (including the empty string) is false.
|
||||||
|
func parseBoolParam(s string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzinessKnobs maps the high-level `exact` flag to the two Typesense params
|
||||||
|
// that actually disable fuzzy matching:
|
||||||
|
//
|
||||||
|
// exact=false → prefix=true, num_typos=2 (default forgiving search)
|
||||||
|
// exact=true → prefix=false, num_typos=0 (literal-keyword only)
|
||||||
|
//
|
||||||
|
// Stopword filtering is left unchanged in both modes.
|
||||||
|
func fuzzinessKnobs(exact bool) (prefix, numTypos string) {
|
||||||
|
if exact {
|
||||||
|
return "false", "0"
|
||||||
|
}
|
||||||
|
return "true", "2"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,607 @@
|
|||||||
|
// Package handler — social endpoints for posts, bookmarks, collections, and
|
||||||
|
// public profiles. Three small handler structs share this file because each
|
||||||
|
// is itself just a thin shim over its service. mapSocialErr at the bottom
|
||||||
|
// centralizes the error→HTTP-status mapping so every endpoint surfaces the
|
||||||
|
// same shapes for plan-limit / forbidden / not-found.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PostHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type PostHandler struct {
|
||||||
|
svc *service.PostService
|
||||||
|
graph *service.SocialGraphService // post reporting
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPostHandler(svc *service.PostService, graph *service.SocialGraphService) *PostHandler {
|
||||||
|
return &PostHandler{svc: svc, graph: graph}
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportPostReq struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report files a report against this post. Body: {category, reason?}.
|
||||||
|
// Auth-required; the reporter is the signed-in viewer.
|
||||||
|
func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req reportPostReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.graph.ReportPost(r.Context(), uid, id, req.Category, req.Reason); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPostReq struct {
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
ImageURLs []string `json:"image_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
var req createPostReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := validator.New()
|
||||||
|
validator.Required(v, "caption", req.Caption)
|
||||||
|
validator.Required(v, "source_url", req.SourceURL)
|
||||||
|
if !v.Valid() {
|
||||||
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reshare materialises a reshare of the post identified by {id} as a
|
||||||
|
// new posts row owned by the caller. Idempotent — second call returns
|
||||||
|
// the same row (still 201). The body is the new (or existing) post so
|
||||||
|
// the frontend can drop it into its local feed without a refetch.
|
||||||
|
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
p, err := h.svc.Reshare(r.Context(), id, uid)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreshare removes a previously recorded reshare. Idempotent.
|
||||||
|
func (h *PostHandler) Unreshare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.svc.Unreshare(r.Context(), id, uid); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
// viewerID is "" for anonymous viewers — the service treats that as
|
||||||
|
// "no reshare hydration" so the public-cache shape is unchanged.
|
||||||
|
p, err := h.svc.Get(r.Context(), id, middleware.UserID(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
type replaceImagesReq struct {
|
||||||
|
ImageURLs []string `json:"image_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceImages swaps the post's image set. Body: {"image_urls": [...]}.
|
||||||
|
// An empty array removes all images (used for the "remove background" flow).
|
||||||
|
// Owner-only; non-owners and missing posts both 404 to avoid leaking IDs.
|
||||||
|
func (h *PostHandler) ReplaceImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req replaceImagesReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Defensive cap — posts in this app only display one background, but
|
||||||
|
// the schema supports many. Cap at 5 to match the compose-post limit.
|
||||||
|
if len(req.ImageURLs) > 5 {
|
||||||
|
response.Error(w, http.StatusBadRequest, "at most 5 images")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.svc.ReplaceImages(r.Context(), id, uid, req.ImageURLs)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
type imagePresignReq struct {
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PostHandler) ImagePresign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
var req imagePresignReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upload, public, err := h.svc.ImagePresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]string{
|
||||||
|
"upload_url": upload,
|
||||||
|
"public_url": public,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BookmarkHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BookmarkHandler struct{ svc *service.BookmarkService }
|
||||||
|
|
||||||
|
func NewBookmarkHandler(svc *service.BookmarkService) *BookmarkHandler {
|
||||||
|
return &BookmarkHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createBookmarkReq struct {
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
Excerpt *string `json:"excerpt"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BookmarkHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
var req createBookmarkReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.SourceURL) == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "source_url is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := h.svc.Create(r.Context(), uid, req.SourceURL, req.SourceTitle, req.Excerpt, req.Note)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BookmarkHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
out, err := h.svc.List(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *BookmarkHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CollectionHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type CollectionHandler struct {
|
||||||
|
svc *service.CollectionService
|
||||||
|
postRepo *repository.PostRepo
|
||||||
|
auth *service.AuthService // unused for now; placeholder for follow/mute later
|
||||||
|
issuer *auth.Issuer // used to detect viewer id on optionally-authed routes
|
||||||
|
users *repository.UserRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollectionHandler(svc *service.CollectionService, posts *repository.PostRepo, issuer *auth.Issuer, users *repository.UserRepo) *CollectionHandler {
|
||||||
|
return &CollectionHandler{svc: svc, postRepo: posts, issuer: issuer, users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createCollectionReq struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
CoverURL *string `json:"cover_url"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
var req createCollectionReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := h.svc.Create(r.Context(), uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateCollectionReq struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
CoverURL *string `json:"cover_url"`
|
||||||
|
IsPublic *bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req updateCollectionReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := h.svc.Update(r.Context(), id, uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
out, err := h.svc.ListMine(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get is public-or-private depending on the collection's `is_public` flag.
|
||||||
|
// Anonymous viewers can read public collections; private ones require the
|
||||||
|
// owner to be logged in. We try to read the JWT cookie best-effort to
|
||||||
|
// detect the viewer's identity.
|
||||||
|
func (h *CollectionHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
viewerID := optionalUserID(r, h.issuer, h.users)
|
||||||
|
c, posts, err := h.svc.Get(r.Context(), id, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"collection": c,
|
||||||
|
"posts": posts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectionItemReq struct {
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) AddItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
collectionID := chi.URLParam(r, "id")
|
||||||
|
var req collectionItemReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.AddItem(r.Context(), collectionID, req.PostID, uid); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) RemoveItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
collectionID := chi.URLParam(r, "id")
|
||||||
|
postID := chi.URLParam(r, "post_id")
|
||||||
|
if err := h.svc.RemoveItem(r.Context(), collectionID, postID, uid); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
type reorderItemsReq struct {
|
||||||
|
PostIDs []string `json:"post_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderItems takes an ordered list of post ids and rewrites their
|
||||||
|
// positions in the collection. Owner-only. Body: {"post_ids": [...]}.
|
||||||
|
func (h *CollectionHandler) ReorderItems(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
collectionID := chi.URLParam(r, "id")
|
||||||
|
var req reorderItemsReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.SetItemOrder(r.Context(), collectionID, uid, req.PostIDs); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainingPost answers "which of my collections already contain post
|
||||||
|
// X?" — used by the save-to-collection drawer so a post can't be added
|
||||||
|
// twice. Response: {"collection_ids": ["uuid", ...]}.
|
||||||
|
func (h *CollectionHandler) ContainingPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
postID := chi.URLParam(r, "post_id")
|
||||||
|
ids, err := h.svc.CollectionsContaining(r.Context(), postID, uid)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string][]string{
|
||||||
|
"collection_ids": ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CollectionHandler) CoverPresign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
var req imagePresignReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upload, public, err := h.svc.CoverPresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]string{
|
||||||
|
"upload_url": upload,
|
||||||
|
"public_url": public,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ProfileHandler — public /api/v1/u/{handle}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ProfileHandler struct {
|
||||||
|
users *repository.UserRepo
|
||||||
|
posts *repository.PostRepo
|
||||||
|
collections *repository.CollectionRepo
|
||||||
|
graph *service.SocialGraphService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService) *ProfileHandler {
|
||||||
|
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handle := chi.URLParam(r, "handle")
|
||||||
|
u, err := h.users.FindByHandle(r.Context(), handle)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := h.posts.CountByUser(r.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load collections")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Follower/following counts + the viewer's relationship to this
|
||||||
|
// profile. viewerID is "" for anonymous viewers (OptionalAuth) and
|
||||||
|
// equal to u.ID when the owner hits their own /u/{handle} — both
|
||||||
|
// cases yield a nil `relationship` so the frontend hides the
|
||||||
|
// follow/block affordances.
|
||||||
|
viewerID := middleware.UserID(r.Context())
|
||||||
|
followers, following, rel, err := h.graph.ProfileExtras(r.Context(), viewerID, u)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load relationship")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"handle": u.Handle,
|
||||||
|
"name": u.Name,
|
||||||
|
"avatar_url": u.AvatarURL,
|
||||||
|
"joined_at": u.CreatedAt,
|
||||||
|
"country": u.Country,
|
||||||
|
"post_count": count,
|
||||||
|
"follower_count": followers,
|
||||||
|
"following_count": following,
|
||||||
|
"relationship": rel,
|
||||||
|
"public_collections": cols,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed returns posts for the public feed. Same payload as ListMine but
|
||||||
|
// looked up by handle so it's reachable anonymously. When an
|
||||||
|
// authenticated viewer hits this route (via OptionalAuth) each post
|
||||||
|
// carries `viewer_has_reshared` so the action footer renders the right
|
||||||
|
// button. Anonymous viewers get `false` for every post at zero cost.
|
||||||
|
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handle := chi.URLParam(r, "handle")
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
posts, total, err := h.posts.ListByHandle(r.Context(), handle, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewerID := middleware.UserID(r.Context())
|
||||||
|
if viewerID != "" && len(posts) > 0 {
|
||||||
|
ids := make([]string, 0, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
// Skip own posts — can't reshare yourself, and the swap
|
||||||
|
// already triggers on isOwner.
|
||||||
|
if posts[i].UserID != viewerID {
|
||||||
|
ids = append(ids, posts[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
if states[posts[i].ID] {
|
||||||
|
posts[i].ViewerHasReshared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func mapSocialErr(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrUserNotFound),
|
||||||
|
errors.Is(err, repository.ErrPostNotFound),
|
||||||
|
errors.Is(err, repository.ErrBookmarkNotFound),
|
||||||
|
errors.Is(err, repository.ErrCollectionNotFound),
|
||||||
|
errors.Is(err, service.ErrNotFound):
|
||||||
|
response.Error(w, http.StatusNotFound, "not found")
|
||||||
|
case errors.Is(err, service.ErrForbidden):
|
||||||
|
response.Error(w, http.StatusForbidden, "not allowed")
|
||||||
|
case errors.Is(err, service.ErrBlocked):
|
||||||
|
response.Error(w, http.StatusForbidden, "this action isn't available between you and this user")
|
||||||
|
case errors.Is(err, service.ErrPlanLimit):
|
||||||
|
response.Error(w, http.StatusPaymentRequired, err.Error())
|
||||||
|
default:
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLimitOffset(r *http.Request) (limit, offset int) {
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func paginationMeta(total, limit, offset int) *response.Meta {
|
||||||
|
if limit == 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
totalPages := int(math.Ceil(float64(total) / float64(limit)))
|
||||||
|
return &response.Meta{
|
||||||
|
Page: (offset / limit) + 1,
|
||||||
|
Limit: limit,
|
||||||
|
Total: int64(total),
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalUserID reads the auth cookie if present and verifies the JWT —
|
||||||
|
// used by routes that show different data to logged-in vs anonymous viewers
|
||||||
|
// (e.g. private collections). Returns "" on any failure (treated as anon).
|
||||||
|
func optionalUserID(r *http.Request, issuer *auth.Issuer, users *repository.UserRepo) string {
|
||||||
|
raw := auth.Read(r)
|
||||||
|
if raw == "" || issuer == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
claims, err := issuer.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if users != nil {
|
||||||
|
u, err := users.FindByID(r.Context(), claims.UserID)
|
||||||
|
if err != nil || u.TokenVersion != claims.TokenVersion {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return claims.UserID
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsHandler renders a single-page HTML snapshot of the system —
|
||||||
|
// aggregate counts plus the most-recent feedback rows. Mounted at /stats
|
||||||
|
// (outside /api/v1, since it returns HTML not JSON) and intentionally
|
||||||
|
// public per current product call. If that ever needs to change, wrap
|
||||||
|
// the route in middleware in server.go.
|
||||||
|
//
|
||||||
|
// It also handles the "Mark resolved" form POST per feedback row, which
|
||||||
|
// stamps resolved_at (one-way) and fires the author thank-you email via
|
||||||
|
// the feedback service.
|
||||||
|
type StatsHandler struct {
|
||||||
|
stats *repository.StatsRepo
|
||||||
|
feedback *repository.FeedbackRepo
|
||||||
|
svc *service.FeedbackService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
|
||||||
|
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
|
||||||
|
snap, err := h.stats.Snapshot(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "stats query failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recent, err := h.feedback.Recent(r.Context(), 25)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "recent feedback query failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// Caching off — this is an ops snapshot; staleness defeats the point.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if err := statsTpl.Execute(w, map[string]any{
|
||||||
|
"Stats": snap,
|
||||||
|
"Feedback": recent,
|
||||||
|
"GeneratedAt": time.Now().Format("2006-01-02 15:04:05 MST"),
|
||||||
|
}); err != nil {
|
||||||
|
// Headers already flushed — best we can do is bail mid-stream.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
|
||||||
|
// the row acknowledged (one-way) and emails the author that we've seen
|
||||||
|
// it and started work, then 303-redirects back to /stats. Already-
|
||||||
|
// acknowledged or unknown ids redirect silently.
|
||||||
|
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
|
||||||
|
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/stats", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
|
||||||
|
// the row resolved (one-way) and fires the author thank-you email, then
|
||||||
|
// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
|
||||||
|
// resolved or unknown ids redirect silently — the desired end state is
|
||||||
|
// already true.
|
||||||
|
func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
|
||||||
|
http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/stats", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
|
||||||
|
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||||
|
}).Parse(statsHTML))
|
||||||
|
|
||||||
|
const statsHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Stats · Prabhupāda Says</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 920px; margin: 2.5rem auto; padding: 0 1.5rem; color: #1a1a1a; line-height: 1.5 }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.25rem }
|
||||||
|
h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 0.25rem }
|
||||||
|
.meta { color: #888; font-size: 0.8rem; margin-bottom: 1.5rem }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem }
|
||||||
|
.card { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem }
|
||||||
|
.card .num { font-size: 1.4rem; font-weight: 600; line-height: 1.1 }
|
||||||
|
.card .num .delta { font-size: 0.75rem; font-weight: 500; color: #1e7a4a; margin-left: 0.4rem }
|
||||||
|
.card .label { color: #666; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.35rem }
|
||||||
|
.row { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; font-size: 0.92rem }
|
||||||
|
.row:last-child { border-bottom: 0 }
|
||||||
|
.row .cat { font-weight: 600; text-transform: capitalize }
|
||||||
|
.fb { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem; margin-bottom: 0.6rem }
|
||||||
|
.fb.resolved { background: #f3f8f4; border-color: #d6e9da }
|
||||||
|
.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap; align-items: center }
|
||||||
|
.fb .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
|
||||||
|
.fb .msg { white-space: pre-wrap; font-size: 0.95rem }
|
||||||
|
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem }
|
||||||
|
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
|
||||||
|
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
|
||||||
|
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
|
||||||
|
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
||||||
|
.resolve-btn:hover { background: #196b40 }
|
||||||
|
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
||||||
|
.ack-btn:hover { background: #faf5ea }
|
||||||
|
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
|
||||||
|
.empty { color: #888; font-style: italic }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>System snapshot</h1>
|
||||||
|
<p class="meta">Generated {{.GeneratedAt}}</p>
|
||||||
|
|
||||||
|
<h2>Users & activity</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="label">Users</div><div class="num">{{.Stats.Users}}<span class="delta">+{{.Stats.UsersLast7Days}} / 7d</span></div></div>
|
||||||
|
<div class="card"><div class="label">Posts</div><div class="num">{{.Stats.Posts}}<span class="delta">+{{.Stats.PostsLast7Days}} / 7d</span></div></div>
|
||||||
|
<div class="card"><div class="label">Collections</div><div class="num">{{.Stats.Collections}}<span class="delta">{{.Stats.PublicCollections}} public</span></div></div>
|
||||||
|
<div class="card"><div class="label">Bookmarks</div><div class="num">{{.Stats.Bookmarks}}</div></div>
|
||||||
|
<div class="card"><div class="label">Reshares</div><div class="num">{{.Stats.Reshares}}</div></div>
|
||||||
|
<div class="card"><div class="label">Feedback</div><div class="num">{{.Stats.Feedback}}<span class="delta">+{{.Stats.FeedbackLast7Days}} / 7d</span></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Feedback by category</h2>
|
||||||
|
{{if .Stats.FeedbackByCategory}}
|
||||||
|
{{range .Stats.FeedbackByCategory}}
|
||||||
|
<div class="row"><span class="cat">{{.Category}}</span><span>{{.Count}}</span></div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">No feedback yet.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Recent feedback</h2>
|
||||||
|
{{if .Feedback}}
|
||||||
|
{{range .Feedback}}
|
||||||
|
<div class="fb{{if .ResolvedAt}} resolved{{end}}">
|
||||||
|
<div class="head">
|
||||||
|
<span class="cat-pill">{{.Category}}</span>
|
||||||
|
<span>{{fmtTime .CreatedAt}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg">{{.Message}}</div>
|
||||||
|
<div class="author">
|
||||||
|
{{if .AuthorHandle}}
|
||||||
|
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
|
||||||
|
{{else if .ContactEmail}}
|
||||||
|
anonymous · <code>{{.ContactEmail}}</code>
|
||||||
|
{{else}}
|
||||||
|
anonymous
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="foot">
|
||||||
|
{{if .ResolvedAt}}
|
||||||
|
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
|
||||||
|
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
|
||||||
|
{{else}}
|
||||||
|
{{if .AcknowledgedAt}}
|
||||||
|
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
|
||||||
|
<button type="submit" class="ack-btn">Acknowledge</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
|
||||||
|
<button type="submit" class="resolve-btn">Mark resolved</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">No feedback yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserHandler exposes profile-edit + avatar-presign endpoints.
|
||||||
|
// All routes here sit behind middleware.RequireAuth.
|
||||||
|
type UserHandler struct {
|
||||||
|
svc *service.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserHandler(svc *service.UserService) *UserHandler {
|
||||||
|
return &UserHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateProfileReq accepts partial updates. Use pointers so the JSON decoder
|
||||||
|
// can tell "client explicitly sent null" from "field not present" — the
|
||||||
|
// service treats nil-name as "leave alone" and ""-name as an error.
|
||||||
|
type updateProfileReq struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
Handle *string `json:"handle"`
|
||||||
|
Country *string `json:"country"`
|
||||||
|
// Devotional profile (user-editable).
|
||||||
|
Sampradaya *string `json:"sampradaya"`
|
||||||
|
Gender *string `json:"gender"`
|
||||||
|
Initiated *bool `json:"initiated"`
|
||||||
|
InitiatedAt *string `json:"initiated_at"`
|
||||||
|
DailyChantingRounds *int `json:"daily_chanting_rounds"`
|
||||||
|
// System-managed notification fields — accepted here so future logic
|
||||||
|
// can set them via the same endpoint; no UI sends them today.
|
||||||
|
PreferredChantingTime *string `json:"preferred_chanting_time"`
|
||||||
|
PeakActiveTime *string `json:"peak_active_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile patches any subset of the editable profile fields.
|
||||||
|
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
|
||||||
|
var req updateProfileReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.svc.UpdateProfile(r.Context(), uid, service.UpdateProfileInput{
|
||||||
|
Name: req.Name,
|
||||||
|
AvatarURL: req.AvatarURL,
|
||||||
|
Handle: req.Handle,
|
||||||
|
Country: req.Country,
|
||||||
|
Sampradaya: req.Sampradaya,
|
||||||
|
Gender: req.Gender,
|
||||||
|
Initiated: req.Initiated,
|
||||||
|
InitiatedAt: req.InitiatedAt,
|
||||||
|
DailyChantingRounds: req.DailyChantingRounds,
|
||||||
|
PreferredChantingTime: req.PreferredChantingTime,
|
||||||
|
PeakActiveTime: req.PeakActiveTime,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrHandleTaken) {
|
||||||
|
response.Error(w, http.StatusConflict, "that handle is already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userSearchItem is the public-safe slice of model.User returned by the
|
||||||
|
// search endpoint. Sensitive fields (email, password hash, role, plan,
|
||||||
|
// token_version, timestamps) are intentionally omitted.
|
||||||
|
type userSearchItem struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search handles GET /api/v1/users/search?q=...&limit=...
|
||||||
|
//
|
||||||
|
// Public endpoint — anonymous and signed-in viewers alike can search
|
||||||
|
// for users by handle or display name. Returns a sanitized payload
|
||||||
|
// without auth-sensitive fields. Used by the global /search page when
|
||||||
|
// the query carries an "@" prefix.
|
||||||
|
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if q == "" {
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"query": "",
|
||||||
|
"results": []userSearchItem{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 20
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := h.svc.Search(r.Context(), q, limit)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]userSearchItem, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
items = append(items, userSearchItem{
|
||||||
|
Handle: u.Handle,
|
||||||
|
Name: u.Name,
|
||||||
|
AvatarURL: u.AvatarURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"query": q,
|
||||||
|
"results": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadingPrefs returns the caller's saved reading prefs blob (always
|
||||||
|
// well-formed JSON; an empty object when the user has never written).
|
||||||
|
// The frontend treats the response as the source of truth on first
|
||||||
|
// sign-in for this device.
|
||||||
|
func (h *UserHandler) GetReadingPrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
raw, err := h.svc.GetReadingPrefs(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not load reading prefs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Round-trip the JSONB as the data field directly. We use a raw
|
||||||
|
// json.RawMessage so the body is `{"data": {...}}` not a re-encoded
|
||||||
|
// string. The envelope wrapper keeps shape parity with the rest of
|
||||||
|
// the API.
|
||||||
|
response.JSON(w, http.StatusOK, json.RawMessage(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateReadingPrefs replaces the caller's reading-prefs blob with the
|
||||||
|
// request body (which must itself be a JSON object — we re-marshal a
|
||||||
|
// decoded map to enforce that without imposing a fixed schema on a
|
||||||
|
// payload the frontend owns).
|
||||||
|
func (h *UserHandler) UpdateReadingPrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
|
||||||
|
// Decode into map[string]any to guarantee the body is an object (not
|
||||||
|
// an array, number, or string). Re-marshal before persisting so the
|
||||||
|
// stored JSON is canonicalised and any duplicate keys are dropped.
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect an object)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "could not re-encode prefs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.UpdateReadingPrefs(r.Context(), uid, prefsJSON); err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not save reading prefs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, json.RawMessage(prefsJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
type presignReq struct {
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type presignResp struct {
|
||||||
|
UploadURL string `json:"upload_url"`
|
||||||
|
PublicURL string `json:"public_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarPresign returns a one-shot R2 PUT URL the browser uses to upload.
|
||||||
|
// The frontend then PATCHes /users/me with the returned public_url to
|
||||||
|
// persist the new avatar on the user row.
|
||||||
|
func (h *UserHandler) AvatarPresign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
|
||||||
|
var req presignReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := strings.TrimSpace(req.ContentType)
|
||||||
|
if ct == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "content_type is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
upload, public, err := h.svc.AvatarPresign(r.Context(), uid, ct)
|
||||||
|
if err != nil {
|
||||||
|
// Differentiate "feature off" from "bad content type" so the frontend
|
||||||
|
// can show a useful message.
|
||||||
|
if strings.Contains(err.Error(), "not configured") {
|
||||||
|
response.Error(w, http.StatusServiceUnavailable, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, presignResp{UploadURL: upload, PublicURL: public})
|
||||||
|
}
|
||||||
+81
-26
@@ -2,56 +2,111 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type ctxKey string
|
||||||
|
|
||||||
const UserIDKey contextKey = "user_id"
|
const (
|
||||||
|
userIDKey ctxKey = "user_id"
|
||||||
|
tokenVersionKey ctxKey = "tv"
|
||||||
|
)
|
||||||
|
|
||||||
// Auth is a placeholder middleware for JWT authentication.
|
// RequireAuth is the middleware applied to every protected route.
|
||||||
// It extracts the Bearer token from the Authorization header,
|
//
|
||||||
// validates it, and sets the user ID in the request context.
|
// It pulls the JWT from the cookie, verifies signature + expiry, then asks
|
||||||
func Auth(jwtSecret string) func(next http.Handler) http.Handler {
|
// the UserRepo for the current token_version and rejects if the JWT carries
|
||||||
|
// a stale version (password-change or forced logout invalidates older JWTs).
|
||||||
|
//
|
||||||
|
// On success it stuffs (user_id, token_version) into the request context;
|
||||||
|
// handlers read them via UserID() / TokenVersion().
|
||||||
|
func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
raw := auth.Read(r)
|
||||||
if authHeader == "" {
|
if raw == "" {
|
||||||
response.Error(w, http.StatusUnauthorized, "missing authorization header")
|
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
claims, err := issuer.Parse(raw)
|
||||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
if err != nil {
|
||||||
response.Error(w, http.StatusUnauthorized, "invalid authorization format")
|
response.Error(w, http.StatusUnauthorized, "invalid or expired session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := parts[1]
|
u, err := users.FindByID(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
// TODO: validate JWT token and extract user ID
|
if errors.Is(err, repository.ErrUserNotFound) {
|
||||||
_ = token
|
response.Error(w, http.StatusUnauthorized, "account no longer exists")
|
||||||
_ = jwtSecret
|
return
|
||||||
userID := "" // placeholder
|
}
|
||||||
|
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||||
if userID == "" {
|
return
|
||||||
response.Error(w, http.StatusUnauthorized, "invalid or expired token")
|
}
|
||||||
|
if u.TokenVersion != claims.TokenVersion {
|
||||||
|
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||||
|
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserID extracts the user ID from the request context.
|
// OptionalAuth is the soft-auth sibling of RequireAuth. Same logic, but
|
||||||
func GetUserID(ctx context.Context) string {
|
// invalid / missing sessions don't reject the request — they just skip
|
||||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
// the context population. Use it on routes that are reachable
|
||||||
|
// anonymously but want to personalise the response when the viewer
|
||||||
|
// happens to be signed in (e.g. `viewer_has_reshared` on a public
|
||||||
|
// post).
|
||||||
|
func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := auth.Read(r)
|
||||||
|
if raw == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := issuer.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := users.FindByID(r.Context(), claims.UserID)
|
||||||
|
if err != nil || u.TokenVersion != claims.TokenVersion {
|
||||||
|
// Stale or invalid session — proceed anonymously rather
|
||||||
|
// than failing the request. RequireAuth would 401 here.
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||||
|
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserID returns the authenticated user's id from the request context.
|
||||||
|
// Returns "" if RequireAuth has not run.
|
||||||
|
func UserID(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(userIDKey).(string); ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TokenVersion returns the JWT's token_version claim from context.
|
||||||
|
func TokenVersion(ctx context.Context) int {
|
||||||
|
if v, ok := ctx.Value(tokenVersionKey).(int); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Feedback is a user-submitted message — bug report, feature request,
|
||||||
|
// or general comment — captured by the /feedback page. Surfaced to
|
||||||
|
// admins both via the /stats page and via an email fan-out on insert.
|
||||||
|
//
|
||||||
|
// UserID is a pointer so the model can carry anonymous submissions
|
||||||
|
// (logged-out visitors) and orphaned rows (the submitter has since
|
||||||
|
// deleted their account — the FK is set null). ContactEmail is the
|
||||||
|
// optional reply-to filled in by anonymous submitters.
|
||||||
|
//
|
||||||
|
// AuthorHandle / AuthorName / AuthorEmail are populated by joins in
|
||||||
|
// the repo's lookup helpers (used by the admin email template and the
|
||||||
|
// /stats page). They're nil for anonymous rows.
|
||||||
|
type Feedback struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID *string `json:"user_id,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ContactEmail *string `json:"contact_email,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// AcknowledgedAt is set when an admin presses "Acknowledge" on the
|
||||||
|
// /stats page — the team has seen it and (if it needs work) the devs
|
||||||
|
// have started. NULL until then. One-way like ResolvedAt.
|
||||||
|
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||||
|
// ResolvedAt is set the moment an admin presses "Mark resolved" on
|
||||||
|
// the /stats page. NULL while open. The action is one-way — the
|
||||||
|
// repo's MarkResolved guards on `resolved_at IS NULL` so a second
|
||||||
|
// resolve is a no-op.
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
|
||||||
|
AuthorHandle *string `json:"author_handle,omitempty"`
|
||||||
|
AuthorName *string `json:"author_name,omitempty"`
|
||||||
|
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Post is a public quote on a user's feed. Caption is the verbatim selected
|
||||||
|
// text from the knowledgebase; SourceURL links back to the page the user
|
||||||
|
// was reading. Images are loaded separately via PostImage rows.
|
||||||
|
//
|
||||||
|
// Reshares are real Post rows owned by the resharer, with
|
||||||
|
// ResharedFromPostID pointing to the canonical original. Likes, comments,
|
||||||
|
// and images all hang off the post's own id, so they diverge naturally
|
||||||
|
// across reshare boundaries — no parallel "per-resharer" tables.
|
||||||
|
type Post struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
Images []PostImage `json:"images"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Author info — populated by repo joins for feed listings.
|
||||||
|
AuthorName string `json:"author_name,omitempty"`
|
||||||
|
AuthorHandle string `json:"author_handle,omitempty"`
|
||||||
|
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||||
|
|
||||||
|
// Reshare attribution. ResharedFromPostID is non-nil when this post
|
||||||
|
// is a reshare; the four `Original*` fields carry the canonical
|
||||||
|
// post's author info so the frontend can render a
|
||||||
|
// "Reshared from @handle" link without an extra round-trip.
|
||||||
|
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
|
||||||
|
OriginalAuthorName *string `json:"original_author_name,omitempty"`
|
||||||
|
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
|
||||||
|
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
|
||||||
|
|
||||||
|
// ViewerHasReshared is true when the calling user has reshared the
|
||||||
|
// canonical version of this post. Hydrated by the service layer
|
||||||
|
// after the repo returns posts; always false for anonymous viewers.
|
||||||
|
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
|
||||||
|
ViewerHasReshared bool `json:"viewer_has_reshared"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostImage represents one attached image on a post.
|
||||||
|
type PostImage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmark is a private "save my place" marker.
|
||||||
|
type Bookmark struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
Excerpt *string `json:"excerpt,omitempty"`
|
||||||
|
Note *string `json:"note,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionRecord is a user-curated group of posts.
|
||||||
|
//
|
||||||
|
// Renamed to avoid colliding with the legacy `Collection` model in user.go
|
||||||
|
// from when bookmarks/collections were sketched out earlier. Once that old
|
||||||
|
// model is unused we can drop it; for now this keeps both compiling.
|
||||||
|
type CollectionRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
CoverURL *string `json:"cover_url,omitempty"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
ItemCount int `json:"item_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
|
||||||
|
// expose to anyone, plus aggregate counts and the user's posts.
|
||||||
|
type PublicProfile struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
|
PostCount int `json:"post_count"`
|
||||||
|
BookmarkCount int `json:"-"` // never serialized — private
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipState is the viewer's relationship to a target profile,
|
||||||
|
// computed fresh per request from the follows + blocks tables. Every
|
||||||
|
// field is "from the viewer looking at the target":
|
||||||
|
//
|
||||||
|
// - Following: viewer → target follow edge is accepted.
|
||||||
|
// - FollowRequested: viewer → target follow edge is pending (awaiting
|
||||||
|
// the target's approval). The "Requested" button state.
|
||||||
|
// - FollowsYou: target → viewer follow edge is accepted (they
|
||||||
|
// already follow the viewer). Drives the "Follow back" label and
|
||||||
|
// the "Remove from followers" menu item.
|
||||||
|
// - RequestsYou: target → viewer follow edge is pending (the target
|
||||||
|
// asked to follow the viewer). Drives the Accept / Reject buttons.
|
||||||
|
// - Blocked: viewer has blocked the target.
|
||||||
|
// - BlockedBy: target has blocked the viewer.
|
||||||
|
//
|
||||||
|
// nil (not this struct's zero value) is returned for anonymous viewers
|
||||||
|
// and for a user viewing their own profile — callers special-case those.
|
||||||
|
type RelationshipState struct {
|
||||||
|
Following bool `json:"following"`
|
||||||
|
FollowRequested bool `json:"follow_requested"`
|
||||||
|
FollowsYou bool `json:"follows_you"`
|
||||||
|
RequestsYou bool `json:"requests_you"`
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
BlockedBy bool `json:"blocked_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow is one row of the follows table — a directed, request-based
|
||||||
|
// edge from Follower to Following. Status is "pending" until the target
|
||||||
|
// accepts.
|
||||||
|
type Follow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FollowerID string `json:"follower_id"`
|
||||||
|
FollowingID string `json:"following_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
RespondedAt *time.Time `json:"responded_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBlock is one row of user_blocks — Blocker has blocked Blocked.
|
||||||
|
type UserBlock struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BlockerID string `json:"blocker_id"`
|
||||||
|
BlockedID string `json:"blocked_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserReport is one row of user_reports — Reporter flagged Reported.
|
||||||
|
type UserReport struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ReporterID string `json:"reporter_id"`
|
||||||
|
ReportedID string `json:"reported_id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostReport is one row of post_reports — Reporter flagged a specific
|
||||||
|
// post. Reason is optional ("" when none given).
|
||||||
|
type PostReport struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ReporterID string `json:"reporter_id"`
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification is one delivered/queued notification for a recipient
|
||||||
|
// (UserID). ActorID is who triggered it; Kind is a short machine string.
|
||||||
|
// Hydrated Actor* fields are filled by repo joins for list rendering.
|
||||||
|
type Notification struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ActorID *string `json:"actor_id,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||||
|
|
||||||
|
ActorName *string `json:"actor_name,omitempty"`
|
||||||
|
ActorHandle *string `json:"actor_handle,omitempty"`
|
||||||
|
ActorAvatar *string `json:"actor_avatar_url,omitempty"`
|
||||||
|
}
|
||||||
+103
-38
@@ -1,51 +1,116 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// User represents a user in PostgreSQL.
|
// User represents a row in the `users` table.
|
||||||
|
//
|
||||||
|
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
|
||||||
|
// even if a caller accidentally passes a User through response.JSON. It's a
|
||||||
|
// pointer because OAuth-only users have no password — *PasswordHash == nil
|
||||||
|
// means "this account can only sign in via a linked OAuth provider".
|
||||||
|
//
|
||||||
|
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
|
||||||
|
// when the user has not yet verified, instead of an omitted field — the
|
||||||
|
// frontend uses this to decide whether to show the "verify your email" ribbon.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
// Email is nullable: OAuth users (typically Facebook accounts with no
|
||||||
Name string `json:"name"`
|
// verified email on file) sign up without one and can fill it in later
|
||||||
Avatar *string `json:"avatar,omitempty"`
|
// from their profile.
|
||||||
Role string `json:"role"` // "user" | "admin" | "moderator"
|
Email *string `json:"email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Handle string `json:"handle"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Plan string `json:"plan"` // "free" | "premium"
|
||||||
|
// Country is the account's ISO-3166-1 alpha-2 code (e.g. "IN"),
|
||||||
|
// captured best-effort at signup and editable from the profile form.
|
||||||
|
// nil until known.
|
||||||
|
Country *string `json:"country"`
|
||||||
|
|
||||||
|
// --- Devotional profile (user-editable) ---
|
||||||
|
// Gender is a free-but-constrained string ("male"|"female"|"other"),
|
||||||
|
// nil when unspecified.
|
||||||
|
Gender *string `json:"gender"`
|
||||||
|
// Sampradaya is the devotee's spiritual lineage (a lowercase code,
|
||||||
|
// e.g. "gaudiya"). Optional — new accounts default to "gaudiya" but it
|
||||||
|
// can be cleared, so nil means "not specified".
|
||||||
|
Sampradaya *string `json:"sampradaya"`
|
||||||
|
// Initiated marks whether the devotee has received initiation.
|
||||||
|
Initiated bool `json:"initiated"`
|
||||||
|
// InitiatedAt is the initiation date (DATE column), nil when unknown or
|
||||||
|
// not initiated.
|
||||||
|
InitiatedAt *time.Time `json:"initiated_at"`
|
||||||
|
// DailyChantingRounds is the devotee's daily japa round vow (1–1008),
|
||||||
|
// nil when unspecified.
|
||||||
|
DailyChantingRounds *int `json:"daily_chanting_rounds"`
|
||||||
|
|
||||||
|
// --- Notification scheduling (system-managed; no UI yet) ---
|
||||||
|
// PreferredChantingTime / PeakActiveTime store a local clock time as
|
||||||
|
// "HH:MM". They're written silently by the backend to time automated
|
||||||
|
// notifications; the scheduling logic ships later. nil until computed.
|
||||||
|
PreferredChantingTime *string `json:"preferred_chanting_time"`
|
||||||
|
PeakActiveTime *string `json:"peak_active_time"`
|
||||||
|
|
||||||
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
|
PasswordHash *string `json:"-"`
|
||||||
|
TokenVersion int `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bookmark represents a user's bookmarked content.
|
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||||
type Bookmark struct {
|
// "Change password" vs "Set a password" for OAuth-only accounts, without
|
||||||
ID string `json:"id"`
|
// the password hash itself ever leaving the server.
|
||||||
UserID string `json:"user_id"`
|
func (u User) MarshalJSON() ([]byte, error) {
|
||||||
ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc.
|
type alias User
|
||||||
ContentID string `json:"content_id"`
|
return json.Marshal(&struct {
|
||||||
ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1")
|
alias
|
||||||
SelectedText *string `json:"selected_text,omitempty"`
|
HasPassword bool `json:"has_password"`
|
||||||
Note *string `json:"note,omitempty"`
|
}{
|
||||||
CreatedAt time.Time `json:"created_at"`
|
alias: alias(u),
|
||||||
|
HasPassword: u.PasswordHash != nil,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collection represents a user-created collection.
|
// AuthToken represents a row in the `auth_tokens` table.
|
||||||
type Collection struct {
|
// Used for both password reset and email verification flows.
|
||||||
ID string `json:"id"`
|
type AuthToken struct {
|
||||||
UserID string `json:"user_id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
UserID string `json:"user_id"`
|
||||||
Description *string `json:"description,omitempty"`
|
Purpose string `json:"purpose"` // "password_reset" | "email_verification"
|
||||||
IsPublic bool `json:"is_public"`
|
TokenHash string `json:"-"`
|
||||||
ItemCount int `json:"item_count"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mention represents a mention of content shared by a user.
|
const (
|
||||||
type Mention struct {
|
PurposePasswordReset = "password_reset"
|
||||||
ID string `json:"id"`
|
PurposeEmailVerification = "email_verification"
|
||||||
UserID string `json:"user_id"`
|
// PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
|
||||||
ContentType string `json:"content_type"`
|
// account has been unverified for >30 days. Verification happens
|
||||||
ContentID string `json:"content_id"`
|
// per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
|
||||||
ContentRef string `json:"content_ref"`
|
// offline by hash collision — only online against rate limits.
|
||||||
Text string `json:"text"`
|
PurposeLoginOTP = "login_otp"
|
||||||
CreatedAt time.Time `json:"created_at"`
|
)
|
||||||
|
|
||||||
|
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
|
||||||
|
// per (provider, provider_user_id). A single user can have several linked
|
||||||
|
// providers.
|
||||||
|
type OAuthAccount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ProviderUserID string `json:"provider_user_id"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShortURL represents a shortened URL.
|
// ShortURL represents a shortened URL.
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrBookmarkNotFound = errors.New("bookmark not found")
|
||||||
|
var ErrBookmarkExists = errors.New("bookmark already exists for this URL")
|
||||||
|
|
||||||
|
type BookmarkRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewBookmarkRepo(pg *Postgres) *BookmarkRepo { return &BookmarkRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a new bookmark. ErrBookmarkExists on (user_id, source_url) collision.
|
||||||
|
func (r *BookmarkRepo) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
|
||||||
|
b := &model.Bookmark{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO bookmarks (user_id, source_url, source_title, excerpt, note)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at`,
|
||||||
|
userID, sourceURL, sourceTitle, excerpt, note,
|
||||||
|
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "23505") {
|
||||||
|
return nil, ErrBookmarkExists
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all bookmarks for the caller, newest first.
|
||||||
|
func (r *BookmarkRepo) ListByUser(ctx context.Context, userID string) ([]model.Bookmark, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
|
||||||
|
FROM bookmarks WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query bookmarks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Bookmark{}
|
||||||
|
for rows.Next() {
|
||||||
|
var b model.Bookmark
|
||||||
|
if err := rows.Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle,
|
||||||
|
&b.Excerpt, &b.Note, &b.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan bookmark: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUser is used by the plan-limit enforcer.
|
||||||
|
func (r *BookmarkRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM bookmarks WHERE user_id = $1`, userID).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count bookmarks: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a bookmark belonging to the caller.
|
||||||
|
func (r *BookmarkRepo) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM bookmarks WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete bookmark: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrBookmarkNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one bookmark (used for the owner-check path).
|
||||||
|
func (r *BookmarkRepo) FindByID(ctx context.Context, id string) (*model.Bookmark, error) {
|
||||||
|
b := &model.Bookmark{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
|
||||||
|
FROM bookmarks WHERE id = $1`, id,
|
||||||
|
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrBookmarkNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find bookmark: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCollectionNotFound = errors.New("collection not found")
|
||||||
|
|
||||||
|
type CollectionRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewCollectionRepo(pg *Postgres) *CollectionRepo { return &CollectionRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a new collection owned by userID.
|
||||||
|
func (r *CollectionRepo) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
|
||||||
|
c := &model.CollectionRecord{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO collections (user_id, title, description, cover_url, is_public)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`,
|
||||||
|
userID, title, description, coverURL, isPublic,
|
||||||
|
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create collection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one collection by id (no owner check — caller decides
|
||||||
|
// what to do with the visibility flag).
|
||||||
|
func (r *CollectionRepo) FindByID(ctx context.Context, id string) (*model.CollectionRecord, error) {
|
||||||
|
c := &model.CollectionRecord{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
|
||||||
|
FROM collections WHERE id = $1`, id,
|
||||||
|
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrCollectionNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find collection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns all collections for the caller (own listing).
|
||||||
|
func (r *CollectionRepo) ListByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
|
||||||
|
return r.list(ctx, `WHERE user_id = $1 ORDER BY updated_at DESC`, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicByUser returns only the public collections for /u/<handle>.
|
||||||
|
func (r *CollectionRepo) ListPublicByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
|
||||||
|
return r.list(ctx, `WHERE user_id = $1 AND is_public = TRUE ORDER BY updated_at DESC`, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUserAndVisibility — used by the plan-limit enforcer
|
||||||
|
// (free: max 5 public, 5 private).
|
||||||
|
func (r *CollectionRepo) CountByUserAndVisibility(ctx context.Context, userID string, isPublic bool) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM collections WHERE user_id = $1 AND is_public = $2`,
|
||||||
|
userID, isPublic,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count collections: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modifies title/description/cover/visibility. Nil pointers mean
|
||||||
|
// "leave unchanged"; isPublic is unconditional (use a pointer if needed).
|
||||||
|
func (r *CollectionRepo) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
|
||||||
|
const q = `
|
||||||
|
UPDATE collections
|
||||||
|
SET title = COALESCE($3, title),
|
||||||
|
description = COALESCE($4, description),
|
||||||
|
cover_url = CASE WHEN $5::boolean THEN $6 ELSE cover_url END,
|
||||||
|
is_public = COALESCE($7, is_public),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`
|
||||||
|
updateCover := coverURL != nil
|
||||||
|
var coverVal *string
|
||||||
|
if updateCover && *coverURL != "" {
|
||||||
|
coverVal = coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &model.CollectionRecord{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, id, userID, title, description, updateCover, coverVal, isPublic).
|
||||||
|
Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrCollectionNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("update collection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a collection (cascades to collection_items via FK).
|
||||||
|
func (r *CollectionRepo) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM collections WHERE id = $1 AND user_id = $2`, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete collection: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrCollectionNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem inserts a post into a collection. Idempotent — ON CONFLICT.
|
||||||
|
func (r *CollectionRepo) AddItem(ctx context.Context, collectionID, postID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO collection_items (collection_id, post_id, position)
|
||||||
|
VALUES ($1, $2, COALESCE(
|
||||||
|
(SELECT MAX(position) + 1 FROM collection_items WHERE collection_id = $1),
|
||||||
|
0
|
||||||
|
))
|
||||||
|
ON CONFLICT (collection_id, post_id) DO NOTHING`,
|
||||||
|
collectionID, postID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add collection item: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemOrder rewrites positions for the given posts so the new order
|
||||||
|
// matches the input array. Runs in a single transaction so concurrent
|
||||||
|
// reads see either the old order or the new one — never a half-applied
|
||||||
|
// state. Posts not in the input list are left untouched (they keep
|
||||||
|
// whatever position they had); callers should pass the full membership
|
||||||
|
// when they want a clean ordering.
|
||||||
|
func (r *CollectionRepo) SetItemOrder(ctx context.Context, collectionID string, postIDs []string) error {
|
||||||
|
if len(postIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
for i, pid := range postIDs {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE collection_items SET position = $1
|
||||||
|
WHERE collection_id = $2 AND post_id = $3`,
|
||||||
|
i, collectionID, pid,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set position %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem deletes a post from a collection.
|
||||||
|
func (r *CollectionRepo) RemoveItem(ctx context.Context, collectionID, postID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM collection_items WHERE collection_id = $1 AND post_id = $2`,
|
||||||
|
collectionID, postID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove collection item: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionsContaining returns the ids of collections owned by
|
||||||
|
// `userID` that already include `postID`. The drawer uses this to grey
|
||||||
|
// out rows the caller can't add the post to a second time.
|
||||||
|
func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, userID string) ([]string, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT ci.collection_id
|
||||||
|
FROM collection_items ci
|
||||||
|
JOIN collections c ON c.id = ci.collection_id
|
||||||
|
WHERE ci.post_id = $1 AND c.user_id = $2`,
|
||||||
|
postID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collections containing post: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan collection id: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsIn returns the posts inside a collection, ordered by collection
|
||||||
|
// position then added_at. Images are NOT hydrated — call
|
||||||
|
// PostRepo.ImagesFor on the returned ids if your caller renders the
|
||||||
|
// post visually. The CollectionService.Get path already does this.
|
||||||
|
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||||
|
FROM collection_items ci
|
||||||
|
JOIN posts p ON p.id = ci.post_id
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE ci.collection_id = $1
|
||||||
|
ORDER BY ci.position, ci.added_at`,
|
||||||
|
collectionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query collection posts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
posts := []model.Post{}
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.Post
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan post: %w", err)
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate collection posts: %w", err)
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal helpers ---
|
||||||
|
|
||||||
|
func (r *CollectionRepo) list(ctx context.Context, where string, args ...any) ([]model.CollectionRecord, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
|
||||||
|
FROM collections `+where, args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query collections: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.CollectionRecord{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c model.CollectionRecord
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
|
||||||
|
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan collection: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeedbackRepo wraps Postgres access for the feedback table.
|
||||||
|
type FeedbackRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewFeedbackRepo(pg *Postgres) *FeedbackRepo { return &FeedbackRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a single feedback row. userID is a pointer so the
|
||||||
|
// authed and anonymous paths share one method; contactEmail is also a
|
||||||
|
// pointer because authed submissions don't carry one (we read the
|
||||||
|
// submitter's email from the joined user row at notify time).
|
||||||
|
func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
|
||||||
|
f := &model.Feedback{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO feedback (user_id, category, message, contact_email)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, category, message, contact_email, created_at`,
|
||||||
|
userID, category, message, contactEmail,
|
||||||
|
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert feedback: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one feedback row joined with the submitter's
|
||||||
|
// handle + name + email if a user_id is present. Used after Create to
|
||||||
|
// assemble the admin notification email with the proper author block,
|
||||||
|
// and after MarkResolved to assemble the author thank-you email.
|
||||||
|
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
|
||||||
|
f := &model.Feedback{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
|
||||||
|
f.acknowledged_at, f.resolved_at,
|
||||||
|
u.handle, u.name, u.email
|
||||||
|
FROM feedback f
|
||||||
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
WHERE f.id = $1`, id,
|
||||||
|
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
|
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||||
|
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find feedback: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAcknowledged stamps acknowledged_at = now() on a not-yet-
|
||||||
|
// acknowledged row. The `acknowledged_at IS NULL` guard makes this
|
||||||
|
// idempotent and one-way: first call returns true (newly acknowledged →
|
||||||
|
// fire the email), any later call — or an unknown id — returns false.
|
||||||
|
func (r *FeedbackRepo) MarkAcknowledged(ctx context.Context, id string) (acked bool, err error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE feedback
|
||||||
|
SET acknowledged_at = now()
|
||||||
|
WHERE id = $1 AND acknowledged_at IS NULL`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("mark feedback acknowledged: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkResolved stamps resolved_at = now() on a still-open row. The
|
||||||
|
// `resolved_at IS NULL` guard makes this idempotent and one-way: the
|
||||||
|
// first call returns true (newly resolved), any subsequent call — or a
|
||||||
|
// call on an unknown id — returns false so the caller knows NOT to fire
|
||||||
|
// the thank-you email a second time.
|
||||||
|
func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bool, err error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE feedback
|
||||||
|
SET resolved_at = now()
|
||||||
|
WHERE id = $1 AND resolved_at IS NULL`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("mark feedback resolved: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns the most-recent N feedback rows joined with users
|
||||||
|
// for display on /stats. NULL author columns mean anonymous (or the
|
||||||
|
// original submitter's account was since deleted).
|
||||||
|
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 25
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
|
||||||
|
f.acknowledged_at, f.resolved_at,
|
||||||
|
u.handle, u.name
|
||||||
|
FROM feedback f
|
||||||
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT $1`, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query recent feedback: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Feedback{}
|
||||||
|
for rows.Next() {
|
||||||
|
var f model.Feedback
|
||||||
|
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
|
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||||
|
&f.AuthorHandle, &f.AuthorName); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan feedback: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrOAuthAccountNotFound mirrors the other repo sentinels.
|
||||||
|
var ErrOAuthAccountNotFound = errors.New("oauth account not found")
|
||||||
|
|
||||||
|
// OAuthAccountRepo manages provider linkage rows.
|
||||||
|
type OAuthAccountRepo struct {
|
||||||
|
pg *Postgres
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo {
|
||||||
|
return &OAuthAccountRepo{pg: pg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByProvider returns the linkage row for the given (provider, provider_user_id),
|
||||||
|
// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means
|
||||||
|
// this provider identity has been seen before and we just log the user in.
|
||||||
|
func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at
|
||||||
|
FROM oauth_accounts
|
||||||
|
WHERE provider = $1 AND provider_user_id = $2
|
||||||
|
LIMIT 1`
|
||||||
|
a := &model.OAuthAccount{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan(
|
||||||
|
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||||
|
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrOAuthAccountNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find oauth account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProvidersForUser returns the distinct provider names linked to this
|
||||||
|
// user, ordered alphabetically. Used by the settings UI to mark each
|
||||||
|
// social-connect row as "Connected" vs "Connect".
|
||||||
|
func (r *OAuthAccountRepo) ListProvidersForUser(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT DISTINCT provider
|
||||||
|
FROM oauth_accounts
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY provider`
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, q, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list oauth providers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p string
|
||||||
|
if err := rows.Scan(&p); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan oauth provider: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link inserts a new oauth_accounts row tying a provider identity to an
|
||||||
|
// existing user. Idempotent: a second call for the same (provider, sub)
|
||||||
|
// returns the existing row instead of erroring on the unique constraint.
|
||||||
|
func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (provider, provider_user_id) DO UPDATE
|
||||||
|
SET email = EXCLUDED.email,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
avatar_url = EXCLUDED.avatar_url,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at`
|
||||||
|
a := &model.OAuthAccount{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan(
|
||||||
|
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||||
|
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("link oauth account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPostNotFound is returned by lookup methods when no row matches.
|
||||||
|
var ErrPostNotFound = errors.New("post not found")
|
||||||
|
|
||||||
|
// PostRepo wraps Postgres access for posts + post_images.
|
||||||
|
//
|
||||||
|
// Reshare model: a reshare is a real `posts` row owned by the resharer
|
||||||
|
// with `reshared_from_post_id` pointing at the canonical original. Likes
|
||||||
|
// and comments (future) will hang off post.id, so they diverge between
|
||||||
|
// original and reshare for free. Backgrounds are per-row in post_images,
|
||||||
|
// same story. The only thing the original and its reshares share is the
|
||||||
|
// caption / source_url / source_title at the moment of reshare.
|
||||||
|
type PostRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
|
||||||
|
|
||||||
|
// postSelectColumns is the canonical column list used by every reader so
|
||||||
|
// every Post payload carries the same shape — including reshare
|
||||||
|
// attribution. The LEFT JOINs to `original_post` + `original_user` are
|
||||||
|
// always present; for canonical (non-reshare) rows they just yield NULL.
|
||||||
|
const postSelectColumns = `
|
||||||
|
p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
p.created_at, p.updated_at,
|
||||||
|
u.name, u.handle, u.avatar_url,
|
||||||
|
p.reshared_from_post_id,
|
||||||
|
ou.name AS original_author_name,
|
||||||
|
ou.handle AS original_author_handle,
|
||||||
|
ou.avatar_url AS original_author_avatar
|
||||||
|
`
|
||||||
|
|
||||||
|
const postSelectFrom = `
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
|
||||||
|
LEFT JOIN users ou ON ou.id = op.user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
// scanPost decodes one row produced by `postSelectColumns` into the
|
||||||
|
// destination Post. Single source of truth for the column → struct
|
||||||
|
// mapping, used by every reader.
|
||||||
|
func scanPost(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}, p *model.Post) error {
|
||||||
|
return row.Scan(
|
||||||
|
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||||
|
&p.ResharedFromPostID,
|
||||||
|
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a post + its images in a single transaction so we never
|
||||||
|
// end up with orphan images.
|
||||||
|
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
p := &model.Post{}
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO posts (user_id, caption, source_url, source_title)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, caption, source_url, COALESCE(source_title, ''), created_at, updated_at`,
|
||||||
|
userID, caption, sourceURL, sourceTitle,
|
||||||
|
).Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert post: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, url := range imageURLs {
|
||||||
|
var img model.PostImage
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, url, position`,
|
||||||
|
p.ID, url, i,
|
||||||
|
).Scan(&img.ID, &img.URL, &img.Position)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert post image %d: %w", i, err)
|
||||||
|
}
|
||||||
|
p.Images = append(p.Images, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceImages atomically swaps a post's image set. Owner-only — every
|
||||||
|
// reshare is its own posts row now, so the resharer is the owner of
|
||||||
|
// their reshare and edits it just like any other post they made.
|
||||||
|
func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var owner string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1 FOR UPDATE`, postID).Scan(&owner); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("lock post: %w", err)
|
||||||
|
}
|
||||||
|
if owner != userID {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM post_images WHERE post_id = $1`, postID); err != nil {
|
||||||
|
return fmt.Errorf("delete images: %w", err)
|
||||||
|
}
|
||||||
|
for i, url := range imageURLs {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
VALUES ($1, $2, $3)`, postID, url, i,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("insert image %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE posts SET updated_at = now() WHERE id = $1`, postID); err != nil {
|
||||||
|
return fmt.Errorf("touch post: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns one post with its images and author info (plus original
|
||||||
|
// author info if this row is a reshare).
|
||||||
|
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
||||||
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
|
||||||
|
p := &model.Post{}
|
||||||
|
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrPostNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find post: %w", err)
|
||||||
|
}
|
||||||
|
imgs, err := r.ImagesFor(ctx, []string{p.ID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Images = imgs[p.ID]
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByHandle returns the profile feed for a user. Unknown handle →
|
||||||
|
// empty page (matches pre-existing behaviour).
|
||||||
|
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
var userID string
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return []model.Post{}, 0, nil
|
||||||
|
}
|
||||||
|
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
|
||||||
|
}
|
||||||
|
return r.ListByUser(ctx, userID, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns the user's profile feed — every post they own,
|
||||||
|
// authored or reshared. Reshares are interleaved naturally because
|
||||||
|
// they're stored as real rows; sort by created_at gets the right order
|
||||||
|
// without any UNION gymnastics.
|
||||||
|
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
total, err := r.CountByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
ORDER BY p.created_at DESC, p.id
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a post only if it belongs to the caller. Cascades:
|
||||||
|
// - the post's images (post_images.post_id FK)
|
||||||
|
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
||||||
|
// - those reshares' images (post_images.post_id FK on each reshare)
|
||||||
|
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete post: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUser returns the user's total post count (authored + reshared).
|
||||||
|
// Now just a single COUNT against posts.user_id.
|
||||||
|
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count posts: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reshare creates a new posts row owned by `resharerID` that points at
|
||||||
|
// the canonical original via `reshared_from_post_id`. Copies the
|
||||||
|
// original's caption / source_url / source_title and its current images
|
||||||
|
// so the resharer's view renders identically at the moment of reshare;
|
||||||
|
// after that the two diverge — edits to either side never touch the
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// If the post being reshared is itself a reshare we point at *its*
|
||||||
|
// canonical (transitive ancestor), so every reshare always references
|
||||||
|
// the true original.
|
||||||
|
//
|
||||||
|
// Idempotent: a user already resharing a canonical post returns the
|
||||||
|
// existing reshare row instead of creating a second one.
|
||||||
|
//
|
||||||
|
// Service-layer rule (caller can't reshare their own canonical) is
|
||||||
|
// enforced above this method.
|
||||||
|
func (r *PostRepo) Reshare(ctx context.Context, originalPostID, resharerID string) (*model.Post, error) {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
// Load the post being reshared. Walk one level back to canonical
|
||||||
|
// in case the caller is resharing someone else's reshare.
|
||||||
|
var (
|
||||||
|
canonicalID string
|
||||||
|
caption string
|
||||||
|
sourceURL string
|
||||||
|
sourceTitle string
|
||||||
|
canonicalUser string
|
||||||
|
)
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.reshared_from_post_id, p.id),
|
||||||
|
p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
COALESCE(op.user_id, p.user_id)
|
||||||
|
FROM posts p
|
||||||
|
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
|
||||||
|
WHERE p.id = $1`, originalPostID,
|
||||||
|
).Scan(&canonicalID, &caption, &sourceURL, &sourceTitle, &canonicalUser); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrPostNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("load canonical: %w", err)
|
||||||
|
}
|
||||||
|
if canonicalUser == resharerID {
|
||||||
|
return nil, errors.New("cannot reshare your own post")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: existing reshare by this user of this canonical?
|
||||||
|
var existingID string
|
||||||
|
switch err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id FROM posts
|
||||||
|
WHERE user_id = $1 AND reshared_from_post_id = $2
|
||||||
|
LIMIT 1`, resharerID, canonicalID,
|
||||||
|
).Scan(&existingID); {
|
||||||
|
case err == nil:
|
||||||
|
// Already exists — commit the no-op tx and return the existing row.
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return r.FindByID(ctx, existingID)
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
// Fresh reshare path below.
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("check existing reshare: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newID string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO posts (user_id, caption, source_url, source_title, reshared_from_post_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
resharerID, caption, sourceURL, sourceTitle, canonicalID,
|
||||||
|
).Scan(&newID); err != nil {
|
||||||
|
return nil, fmt.Errorf("insert reshare post: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the canonical's current images as the resharer's starting set.
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
SELECT $1, url, position
|
||||||
|
FROM post_images
|
||||||
|
WHERE post_id = $2`, newID, canonicalID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("copy images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return r.FindByID(ctx, newID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreshare removes the caller's reshare of a canonical post. The
|
||||||
|
// caller passes the canonical post id (what the UI knows); we look up
|
||||||
|
// the caller's reshare-row and delete it. Idempotent — no reshare to
|
||||||
|
// delete is a no-op.
|
||||||
|
func (r *PostRepo) Unreshare(ctx context.Context, canonicalPostID, resharerID string) error {
|
||||||
|
if _, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM posts
|
||||||
|
WHERE user_id = $1 AND reshared_from_post_id = $2`,
|
||||||
|
resharerID, canonicalPostID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("unreshare: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReshareStatesFor returns the set of post IDs from the supplied list
|
||||||
|
// that the viewer has reshared. Used by feed renderers to flip the
|
||||||
|
// post-card "Reshare ↔ Send" toggle.
|
||||||
|
//
|
||||||
|
// `postIDs` may carry either canonical posts or reshare rows; the query
|
||||||
|
// resolves each id to its canonical first, then asks "does the viewer
|
||||||
|
// have a row whose reshared_from_post_id equals that canonical?". The
|
||||||
|
// returned map keys are the supplied ids — callers don't need to know
|
||||||
|
// the canonical relationship.
|
||||||
|
func (r *PostRepo) ReshareStatesFor(ctx context.Context, viewerID string, postIDs []string) (map[string]bool, error) {
|
||||||
|
out := make(map[string]bool, len(postIDs))
|
||||||
|
if viewerID == "" || len(postIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT p.id
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.id = ANY($2)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM posts r
|
||||||
|
WHERE r.user_id = $1
|
||||||
|
AND r.reshared_from_post_id = COALESCE(p.reshared_from_post_id, p.id)
|
||||||
|
)`, viewerID, postIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query reshare states: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan reshare state: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal helpers ---
|
||||||
|
|
||||||
|
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("query posts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
posts := []model.Post{}
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.Post
|
||||||
|
if err := scanPost(rows, &p); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan post: %w", err)
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("iterate posts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) > 0 {
|
||||||
|
imgs, err := r.ImagesFor(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
posts[i].Images = imgs[posts[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImagesFor returns a map post_id → [images...] for a batch of post IDs in
|
||||||
|
// a single query so we don't N+1 the feed endpoint. Exported because the
|
||||||
|
// CollectionService needs to hydrate images onto posts that come back
|
||||||
|
// from CollectionRepo.PostsIn (which only joins posts/users — not
|
||||||
|
// post_images — to keep that query focused).
|
||||||
|
func (r *PostRepo) ImagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
|
||||||
|
out := make(map[string][]model.PostImage, len(postIDs))
|
||||||
|
if len(postIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT post_id, id, url, position
|
||||||
|
FROM post_images
|
||||||
|
WHERE post_id = ANY($1)
|
||||||
|
ORDER BY post_id, position`, postIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query post images: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var postID string
|
||||||
|
var img model.PostImage
|
||||||
|
if err := rows.Scan(&postID, &img.ID, &img.URL, &img.Position); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan post image: %w", err)
|
||||||
|
}
|
||||||
|
out[postID] = append(out[postID], img)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R2 wraps an S3 client pointed at Cloudflare's R2 endpoint.
|
||||||
|
//
|
||||||
|
// We use R2 for avatar uploads through presigned PUT URLs:
|
||||||
|
//
|
||||||
|
// frontend asks BFF for a presigned URL ─► BFF returns URL + final public URL
|
||||||
|
// frontend PUTs the file directly to R2 ─► R2 stores it
|
||||||
|
// frontend POSTs the public URL back to BFF profile-update endpoint
|
||||||
|
//
|
||||||
|
// The BFF never proxies the file bytes; bandwidth stays browser↔R2.
|
||||||
|
type R2 struct {
|
||||||
|
client *s3.Client
|
||||||
|
bucket string
|
||||||
|
publicBase string // R2_PUBLIC_URL, no trailing slash
|
||||||
|
}
|
||||||
|
|
||||||
|
// R2Config captures the env vars needed to talk to R2.
|
||||||
|
type R2Config struct {
|
||||||
|
AccountID string
|
||||||
|
AccessKeyID string
|
||||||
|
SecretAccessKey string
|
||||||
|
Bucket string
|
||||||
|
PublicURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured returns true if every required field is present.
|
||||||
|
func (c R2Config) Configured() bool {
|
||||||
|
return c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" &&
|
||||||
|
c.Bucket != "" && c.PublicURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewR2 builds an S3 client pointed at the per-account R2 endpoint.
|
||||||
|
// Returns (nil, nil) if cfg is not Configured — the avatar route will then
|
||||||
|
// return 503 instead of panicking. That keeps the BFF bootable without R2.
|
||||||
|
func NewR2(cfg R2Config) (*R2, error) {
|
||||||
|
if !cfg.Configured() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID)
|
||||||
|
|
||||||
|
creds := credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")
|
||||||
|
client := s3.New(s3.Options{
|
||||||
|
Region: "auto", // R2 ignores region but the SDK requires one
|
||||||
|
Credentials: creds,
|
||||||
|
BaseEndpoint: aws.String(endpoint),
|
||||||
|
UsePathStyle: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &R2{
|
||||||
|
client: client,
|
||||||
|
bucket: cfg.Bucket,
|
||||||
|
publicBase: trimTrailingSlash(cfg.PublicURL),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignPut returns a one-shot PUT URL the browser can use to upload a file
|
||||||
|
// directly to R2, plus the final public URL where the object will be served.
|
||||||
|
//
|
||||||
|
// expiry caps how long the upload URL is valid; 5 minutes is plenty and
|
||||||
|
// limits replay if the URL leaks.
|
||||||
|
func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL, publicURL string, err error) {
|
||||||
|
if r == nil {
|
||||||
|
return "", "", errors.New("R2 not configured")
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return "", "", errors.New("empty key")
|
||||||
|
}
|
||||||
|
|
||||||
|
presigner := s3.NewPresignClient(r.client)
|
||||||
|
req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(r.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
ContentType: aws.String(contentType),
|
||||||
|
}, s3.WithPresignExpires(expiry))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("presign put: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return req.URL, fmt.Sprintf("%s/%s", r.publicBase, key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicURL builds the public URL for an arbitrary key. Useful when reading
|
||||||
|
// existing user.avatar_url values back.
|
||||||
|
func (r *R2) PublicURL(key string) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/%s", r.publicBase, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTrailingSlash(s string) string {
|
||||||
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// social_graph_repo.go bundles the four small repos backing the social
|
||||||
|
// graph — follows, blocks, reports, and notifications. Each is a thin
|
||||||
|
// wrapper over the shared Postgres pool, mirroring the per-domain repo
|
||||||
|
// convention used elsewhere (UserRepo, PostRepo, …). They're grouped in
|
||||||
|
// one file because each is only a handful of queries and they share the
|
||||||
|
// same dependency.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FollowRepo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// FollowRepo manages the request-based follows edge table.
|
||||||
|
type FollowRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewFollowRepo(pg *Postgres) *FollowRepo { return &FollowRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Request inserts a pending follow edge follower→following. Idempotent:
|
||||||
|
// a repeated request (the edge already exists, pending or accepted) is a
|
||||||
|
// no-op. Returns created=true only when a brand-new pending row was
|
||||||
|
// inserted, so the caller fires the "X requested to follow you"
|
||||||
|
// notification exactly once.
|
||||||
|
func (r *FollowRepo) Request(ctx context.Context, followerID, followingID string) (created bool, err error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO follows (follower_id, following_id, status)
|
||||||
|
VALUES ($1, $2, 'pending')
|
||||||
|
ON CONFLICT (follower_id, following_id) DO NOTHING`,
|
||||||
|
followerID, followingID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("request follow: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept flips a pending edge follower→following to accepted and stamps
|
||||||
|
// responded_at. Returns accepted=true only on the transition from
|
||||||
|
// pending (so the "X accepted your request" notification fires once); a
|
||||||
|
// call on an already-accepted or absent edge returns false.
|
||||||
|
func (r *FollowRepo) Accept(ctx context.Context, followerID, followingID string) (accepted bool, err error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE follows
|
||||||
|
SET status = 'accepted', responded_at = now()
|
||||||
|
WHERE follower_id = $1 AND following_id = $2 AND status = 'pending'`,
|
||||||
|
followerID, followingID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("accept follow: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the edge follower→following regardless of status. Backs
|
||||||
|
// unfollow (accepted), cancel-request (own pending outgoing), reject
|
||||||
|
// (their pending incoming), and remove-follower (their accepted edge to
|
||||||
|
// me). Idempotent.
|
||||||
|
func (r *FollowRepo) Delete(ctx context.Context, followerID, followingID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
|
||||||
|
followerID, followingID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete follow: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the status of the edge follower→following ('pending' or
|
||||||
|
// 'accepted'), or "" when no edge exists.
|
||||||
|
func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) (string, error) {
|
||||||
|
var status string
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT status FROM follows WHERE follower_id = $1 AND following_id = $2`,
|
||||||
|
followerID, followingID,
|
||||||
|
).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("follow status: %w", err)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountFollowers counts accepted edges pointing at userID.
|
||||||
|
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM follows WHERE following_id = $1 AND status = 'accepted'`,
|
||||||
|
userID,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count followers: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountFollowing counts accepted edges originating from userID.
|
||||||
|
func (r *FollowRepo) CountFollowing(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM follows WHERE follower_id = $1 AND status = 'accepted'`,
|
||||||
|
userID,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count following: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BlockRepo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// BlockRepo manages user_blocks. Block() also tears down any follow
|
||||||
|
// edges in either direction, so it runs in a transaction.
|
||||||
|
type BlockRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewBlockRepo(pg *Postgres) *BlockRepo { return &BlockRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Block records blocker→blocked (idempotent) and, in the same tx, deletes
|
||||||
|
// any follow edges between the two in either direction — blocking someone
|
||||||
|
// should sever the relationship both ways.
|
||||||
|
func (r *BlockRepo) Block(ctx context.Context, blockerID, blockedID string) error {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin block tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO user_blocks (blocker_id, blocked_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (blocker_id, blocked_id) DO NOTHING`,
|
||||||
|
blockerID, blockedID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("insert block: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM follows
|
||||||
|
WHERE (follower_id = $1 AND following_id = $2)
|
||||||
|
OR (follower_id = $2 AND following_id = $1)`,
|
||||||
|
blockerID, blockedID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("sever follows on block: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit block: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock removes the blocker→blocked record. Idempotent.
|
||||||
|
func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2`,
|
||||||
|
blockerID, blockedID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unblock: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockState reports, in one round-trip, whether a blocked b and whether
|
||||||
|
// b blocked a.
|
||||||
|
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2),
|
||||||
|
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $2 AND blocked_id = $1)`,
|
||||||
|
a, b,
|
||||||
|
).Scan(&aBlockedB, &bBlockedA)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, fmt.Errorf("block state: %w", err)
|
||||||
|
}
|
||||||
|
return aBlockedB, bBlockedA, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ReportRepo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ReportRepo manages user_reports.
|
||||||
|
type ReportRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewReportRepo(pg *Postgres) *ReportRepo { return &ReportRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a user-report row.
|
||||||
|
func (r *ReportRepo) Create(ctx context.Context, reporterID, reportedID, category, reason string) (*model.UserReport, error) {
|
||||||
|
rep := &model.UserReport{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO user_reports (reporter_id, reported_id, category, reason)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, reporter_id, reported_id, category, reason, created_at, resolved_at`,
|
||||||
|
reporterID, reportedID, category, reason,
|
||||||
|
).Scan(&rep.ID, &rep.ReporterID, &rep.ReportedID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create report: %w", err)
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePostReport inserts a post-report row.
|
||||||
|
func (r *ReportRepo) CreatePostReport(ctx context.Context, reporterID, postID, category, reason string) (*model.PostReport, error) {
|
||||||
|
rep := &model.PostReport{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO post_reports (reporter_id, post_id, category, reason)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, reporter_id, post_id, category, reason, created_at, resolved_at`,
|
||||||
|
reporterID, postID, category, reason,
|
||||||
|
).Scan(&rep.ID, &rep.ReporterID, &rep.PostID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create post report: %w", err)
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NotificationRepo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// NotificationRepo persists notification rows. Reads (list / unread
|
||||||
|
// count / mark-read) will be added when the in-app notification center
|
||||||
|
// ships; for now the table is write-only, feeding the email fan-out and
|
||||||
|
// standing ready for websocket/push.
|
||||||
|
type NotificationRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewNotificationRepo(pg *Postgres) *NotificationRepo { return &NotificationRepo{pg: pg} }
|
||||||
|
|
||||||
|
// Create inserts a notification for recipient userID, triggered by
|
||||||
|
// actorID (nil for system notifications), of the given kind.
|
||||||
|
func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *string, kind string) (*model.Notification, error) {
|
||||||
|
n := &model.Notification{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO notifications (user_id, actor_id, kind)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, user_id, actor_id, kind, created_at, read_at`,
|
||||||
|
userID, actorID, kind,
|
||||||
|
).Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create notification: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns a recipient's notifications, newest first, with the
|
||||||
|
// actor's display fields joined in for rendering (nil for system
|
||||||
|
// notifications or since-deleted actors).
|
||||||
|
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT n.id, n.user_id, n.actor_id, n.kind, n.created_at, n.read_at,
|
||||||
|
u.name, u.handle, u.avatar_url
|
||||||
|
FROM notifications n
|
||||||
|
LEFT JOIN users u ON u.id = n.actor_id
|
||||||
|
WHERE n.user_id = $1
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, userID, limit, offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list notifications: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Notification{}
|
||||||
|
for rows.Next() {
|
||||||
|
var n model.Notification
|
||||||
|
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt,
|
||||||
|
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan notification: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnread returns how many of a recipient's notifications are unread.
|
||||||
|
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
|
||||||
|
).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count unread notifications: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllRead stamps read_at on every still-unread notification for the
|
||||||
|
// user. Idempotent.
|
||||||
|
func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL`, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark notifications read: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsRepo runs the aggregate count queries that power /stats. Lives
|
||||||
|
// in its own file rather than spreading COUNT methods across every
|
||||||
|
// domain repo — keeps the at-a-glance "system snapshot" queries in
|
||||||
|
// one place and one trip into the pool.
|
||||||
|
type StatsRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
|
||||||
|
|
||||||
|
// StatsSnapshot bundles totals, last-7-days counts, and feedback
|
||||||
|
// category breakdowns. One value rendered by /stats per request.
|
||||||
|
type StatsSnapshot struct {
|
||||||
|
Users int
|
||||||
|
UsersLast7Days int
|
||||||
|
Posts int
|
||||||
|
PostsLast7Days int
|
||||||
|
Collections int
|
||||||
|
PublicCollections int
|
||||||
|
Bookmarks int
|
||||||
|
Reshares int
|
||||||
|
Feedback int
|
||||||
|
FeedbackLast7Days int
|
||||||
|
FeedbackByCategory []CategoryCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryCount struct {
|
||||||
|
Category string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot runs every count needed by /stats. Queries run in series —
|
||||||
|
// the table sizes are modest enough that chasing parallelism here
|
||||||
|
// would buy sub-ms wins for added complexity.
|
||||||
|
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
||||||
|
s := &StatsSnapshot{}
|
||||||
|
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||||||
|
|
||||||
|
scalars := []struct {
|
||||||
|
sql string
|
||||||
|
args []any
|
||||||
|
dst *int
|
||||||
|
}{
|
||||||
|
{`SELECT COUNT(*) FROM users`, nil, &s.Users},
|
||||||
|
{`SELECT COUNT(*) FROM users WHERE created_at >= $1`, []any{sevenAgo}, &s.UsersLast7Days},
|
||||||
|
{`SELECT COUNT(*) FROM posts`, nil, &s.Posts},
|
||||||
|
{`SELECT COUNT(*) FROM posts WHERE created_at >= $1`, []any{sevenAgo}, &s.PostsLast7Days},
|
||||||
|
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
|
||||||
|
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
|
||||||
|
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks},
|
||||||
|
{`SELECT COUNT(*) FROM posts WHERE reshared_from_post_id IS NOT NULL`, nil, &s.Reshares},
|
||||||
|
{`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback},
|
||||||
|
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
|
||||||
|
}
|
||||||
|
for _, q := range scalars {
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx, q.sql, q.args...).Scan(q.dst); err != nil {
|
||||||
|
return nil, fmt.Errorf("stats scalar %q: %w", q.sql, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT category, COUNT(*)
|
||||||
|
FROM feedback
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY COUNT(*) DESC, category ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("feedback by category: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var cc CategoryCount
|
||||||
|
if err := rows.Scan(&cc.Category, &cc.Count); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan category: %w", err)
|
||||||
|
}
|
||||||
|
s.FeedbackByCategory = append(s.FeedbackByCategory, cc)
|
||||||
|
}
|
||||||
|
return s, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrUserNotFound is returned by lookup methods when no row matches.
|
||||||
|
var ErrUserNotFound = errors.New("user not found")
|
||||||
|
|
||||||
|
// ErrEmailTaken is returned by Create when the email already exists.
|
||||||
|
var ErrEmailTaken = errors.New("email already registered")
|
||||||
|
|
||||||
|
// ErrHandleTaken is returned when the chosen handle collides with an existing user.
|
||||||
|
var ErrHandleTaken = errors.New("handle already in use")
|
||||||
|
|
||||||
|
// UserRepo provides CRUD on the users table.
|
||||||
|
type UserRepo struct {
|
||||||
|
pg *Postgres
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepo(pg *Postgres) *UserRepo {
|
||||||
|
return &UserRepo{pg: pg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userColumns is the canonical column list for SELECT/RETURNING on the
|
||||||
|
// users table, in the exact order scanUser reads them. Keeping the two in
|
||||||
|
// lockstep here is what makes adding a column a single-edit change instead
|
||||||
|
// of a hunt across every query.
|
||||||
|
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
|
email_verified_at, token_version, created_at, updated_at, country,
|
||||||
|
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
||||||
|
preferred_chanting_time, peak_active_time`
|
||||||
|
|
||||||
|
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
||||||
|
// scanUser works for single-row and iterated reads alike.
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanUser reads one users row (selected via userColumns) into u.
|
||||||
|
func scanUser(row rowScanner, u *model.User) error {
|
||||||
|
return row.Scan(
|
||||||
|
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||||
|
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||||
|
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
||||||
|
&u.PreferredChantingTime, &u.PeakActiveTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileUpdate is the set of fields UpdateProfile can change. Every field
|
||||||
|
// is a pointer so "leave unchanged" (nil) is distinct from "set". For the
|
||||||
|
// nullable string fields, a non-nil pointer to "" means "clear to NULL";
|
||||||
|
// DailyChantingRounds uses *0 as its clear sentinel (1 is the real min).
|
||||||
|
type ProfileUpdate struct {
|
||||||
|
Name *string
|
||||||
|
AvatarURL *string
|
||||||
|
Handle *string
|
||||||
|
Country *string
|
||||||
|
Sampradaya *string // nil=leave, ""=clear, code=set
|
||||||
|
Gender *string
|
||||||
|
Initiated *bool
|
||||||
|
InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear
|
||||||
|
DailyChantingRounds *int // 0 to clear
|
||||||
|
PreferredChantingTime *string
|
||||||
|
PeakActiveTime *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
|
||||||
|
// unique-violation. The caller is responsible for picking a unique handle —
|
||||||
|
// the auth service does this with a retry loop.
|
||||||
|
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
|
||||||
|
q := `
|
||||||
|
INSERT INTO users (email, password_hash, name, handle)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING ` + userColumns
|
||||||
|
|
||||||
|
u := &model.User{}
|
||||||
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
||||||
|
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
|
||||||
|
), u)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err, "users_handle_key") {
|
||||||
|
return nil, ErrHandleTaken
|
||||||
|
}
|
||||||
|
if isUniqueViolation(err, "users_email_key") {
|
||||||
|
return nil, ErrEmailTaken
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByEmail returns the user with the given email, or ErrUserNotFound.
|
||||||
|
func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) {
|
||||||
|
return r.findOne(ctx,
|
||||||
|
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
|
||||||
|
// sign-in flow when there's no matching email yet. Email is nullable here:
|
||||||
|
// providers that don't return one (e.g. phone-only Facebook accounts) end
|
||||||
|
// up as users with email=NULL until they fill it in via profile settings.
|
||||||
|
// Same handle-collision retry contract as Create.
|
||||||
|
func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
|
||||||
|
q := `
|
||||||
|
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
|
||||||
|
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
|
||||||
|
RETURNING ` + userColumns
|
||||||
|
var normEmail *string
|
||||||
|
if email != nil {
|
||||||
|
v := strings.ToLower(strings.TrimSpace(*email))
|
||||||
|
if v != "" {
|
||||||
|
normEmail = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u := &model.User{}
|
||||||
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
||||||
|
normEmail, name, handle, avatarURL, emailVerified,
|
||||||
|
), u)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err, "users_handle_key") {
|
||||||
|
return nil, ErrHandleTaken
|
||||||
|
}
|
||||||
|
if isUniqueViolation(err, "users_email_key") {
|
||||||
|
return nil, ErrEmailTaken
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns the user with the given id, or ErrUserNotFound.
|
||||||
|
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||||
|
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByHandle returns the user with the given handle (case-insensitive
|
||||||
|
// thanks to the citext column), or ErrUserNotFound.
|
||||||
|
func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User, error) {
|
||||||
|
return r.findOne(ctx, `WHERE handle = $1`, strings.TrimSpace(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
|
||||||
|
q := `SELECT ` + userColumns + ` FROM users ` + where + ` LIMIT 1`
|
||||||
|
|
||||||
|
u := &model.User{}
|
||||||
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find user: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEmail fills in (or replaces) a user's email and resets email_verified_at
|
||||||
|
// to NULL so a fresh verification flow can run. Returns ErrEmailTaken if
|
||||||
|
// another row already owns this address.
|
||||||
|
//
|
||||||
|
// Used by the "add your email later" flow for OAuth users who signed up
|
||||||
|
// without one. The caller (auth service) is expected to follow up by
|
||||||
|
// minting and sending a verification token.
|
||||||
|
func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User, error) {
|
||||||
|
q := `
|
||||||
|
UPDATE users
|
||||||
|
SET email = $2,
|
||||||
|
email_verified_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING ` + userColumns
|
||||||
|
|
||||||
|
u := &model.User{}
|
||||||
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))), u)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if isUniqueViolation(err, "users_email_key") {
|
||||||
|
return nil, ErrEmailTaken
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("set email: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile applies a partial profile patch. Only the non-nil fields of
|
||||||
|
// `p` are touched; the SQL is built dynamically so an untouched column is
|
||||||
|
// never written (keeps updated_at honest and the patch minimal). For the
|
||||||
|
// nullable string fields a non-nil "" clears the column to NULL.
|
||||||
|
//
|
||||||
|
// `handle` collisions surface as ErrHandleTaken so the handler can render
|
||||||
|
// a useful message.
|
||||||
|
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) {
|
||||||
|
sets := make([]string, 0, 10)
|
||||||
|
args := []any{id}
|
||||||
|
|
||||||
|
// set appends "col = $N" with a bound value.
|
||||||
|
set := func(col string, val any) {
|
||||||
|
args = append(args, val)
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
|
||||||
|
}
|
||||||
|
// setExpr appends a parameterless SET clause (e.g. "col = NULL").
|
||||||
|
setExpr := func(expr string) { sets = append(sets, expr) }
|
||||||
|
// nullableString handles the nil=leave / ""=clear / value=set trio.
|
||||||
|
nullableString := func(col string, v *string) {
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *v == "" {
|
||||||
|
setExpr(col + " = NULL")
|
||||||
|
} else {
|
||||||
|
set(col, *v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Name != nil {
|
||||||
|
set("name", *p.Name)
|
||||||
|
}
|
||||||
|
if p.Handle != nil {
|
||||||
|
set("handle", *p.Handle)
|
||||||
|
}
|
||||||
|
nullableString("avatar_url", p.AvatarURL)
|
||||||
|
nullableString("country", p.Country)
|
||||||
|
nullableString("sampradaya", p.Sampradaya)
|
||||||
|
nullableString("gender", p.Gender)
|
||||||
|
nullableString("preferred_chanting_time", p.PreferredChantingTime)
|
||||||
|
nullableString("peak_active_time", p.PeakActiveTime)
|
||||||
|
if p.Initiated != nil {
|
||||||
|
set("initiated", *p.Initiated)
|
||||||
|
}
|
||||||
|
if p.InitiatedAt != nil {
|
||||||
|
if *p.InitiatedAt == "" {
|
||||||
|
setExpr("initiated_at = NULL")
|
||||||
|
} else {
|
||||||
|
args = append(args, *p.InitiatedAt)
|
||||||
|
sets = append(sets, fmt.Sprintf("initiated_at = $%d::date", len(args)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.DailyChantingRounds != nil {
|
||||||
|
if *p.DailyChantingRounds == 0 {
|
||||||
|
setExpr("daily_chanting_rounds = NULL")
|
||||||
|
} else {
|
||||||
|
set("daily_chanting_rounds", *p.DailyChantingRounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to change — return the current row rather than a no-op UPDATE.
|
||||||
|
if len(sets) == 0 {
|
||||||
|
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
sets = append(sets, "updated_at = now()")
|
||||||
|
|
||||||
|
q := `UPDATE users SET ` + strings.Join(sets, ", ") + ` WHERE id = $1 RETURNING ` + userColumns
|
||||||
|
|
||||||
|
u := &model.User{}
|
||||||
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if isUniqueViolation(err, "users_handle_key") {
|
||||||
|
return nil, ErrHandleTaken
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("update profile: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCountryIfEmpty fills in the account country only when it's still
|
||||||
|
// NULL — used by the best-effort signup capture, so a later manual edit
|
||||||
|
// (or a second capture) never clobbers a value the user chose. `cc` is an
|
||||||
|
// ISO-3166-1 alpha-2 code; callers pass "" to no-op. Errors are the
|
||||||
|
// caller's to swallow (capture is fire-and-forget).
|
||||||
|
func (r *UserRepo) SetCountryIfEmpty(ctx context.Context, id, cc string) error {
|
||||||
|
cc = strings.ToUpper(strings.TrimSpace(cc))
|
||||||
|
if len(cc) != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
UPDATE users SET country = $2, updated_at = now()
|
||||||
|
WHERE id = $1 AND country IS NULL`, id, cc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set country: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword sets a new password hash and bumps token_version so any
|
||||||
|
// existing JWTs immediately become invalid.
|
||||||
|
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
|
||||||
|
const q = `
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = $2,
|
||||||
|
token_version = token_version + 1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, q, id, newHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update password: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns users whose name or handle matches `q` (ILIKE
|
||||||
|
// substring). Leading "@" is stripped — callers can pass either
|
||||||
|
// "@foo" or "foo". Ordering bubbles handle-prefix matches above
|
||||||
|
// name-prefix above the rest, then alphabetic by name as the
|
||||||
|
// tiebreaker.
|
||||||
|
//
|
||||||
|
// Postgres ILIKE is fine at our user-table scale (low six figures
|
||||||
|
// max). When the table grows enough to warrant fuzzy / trigram
|
||||||
|
// matching, add a pg_trgm GIN index and switch to similarity().
|
||||||
|
func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.User, error) {
|
||||||
|
cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@")
|
||||||
|
if cleaned == "" {
|
||||||
|
return []model.User{}, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
substring := "%" + cleaned + "%"
|
||||||
|
prefix := cleaned + "%"
|
||||||
|
|
||||||
|
sql := `SELECT ` + userColumns + `
|
||||||
|
FROM users
|
||||||
|
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN handle::text ILIKE $2 THEN 0
|
||||||
|
WHEN name ILIKE $2 THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
name
|
||||||
|
LIMIT $3`
|
||||||
|
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.User{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u model.User
|
||||||
|
if err := scanUser(rows, &u); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan user: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
|
||||||
|
// prefs (empty object when never set). The frontend owns the shape, so
|
||||||
|
// we round-trip the payload verbatim — no server-side schema enforced.
|
||||||
|
func (r *UserRepo) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
|
||||||
|
const q = `SELECT reading_prefs FROM users WHERE id = $1 LIMIT 1`
|
||||||
|
var raw []byte
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(&raw)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("get reading prefs: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
raw = []byte("{}")
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateReadingPrefs overwrites the user's reading prefs blob. Caller is
|
||||||
|
// responsible for validating that `prefsJSON` is well-formed JSON (the
|
||||||
|
// handler does this by re-marshalling the decoded struct before calling
|
||||||
|
// in).
|
||||||
|
func (r *UserRepo) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
|
||||||
|
const q = `UPDATE users SET reading_prefs = $2::jsonb, updated_at = now() WHERE id = $1`
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, q, id, prefsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update reading prefs: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkEmailVerified sets email_verified_at = now() if not already set.
|
||||||
|
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
|
||||||
|
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
|
||||||
|
WHERE id = $1 AND email_verified_at IS NULL`
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, q, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark verified: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for unique-violation detection. Avoids importing pgconn just for
|
||||||
|
// the SQLSTATE; we string-match on the message which contains the constraint
|
||||||
|
// name. Good enough for the one constraint we care about.
|
||||||
|
func isUniqueViolation(err error, constraint string) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "23505") || strings.Contains(msg, constraint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthTokenRepo handles password_reset + email_verification tokens.
|
||||||
|
type AuthTokenRepo struct {
|
||||||
|
pg *Postgres
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo {
|
||||||
|
return &AuthTokenRepo{pg: pg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the
|
||||||
|
// raw token; only the hash is stored, never the plaintext.
|
||||||
|
func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at`
|
||||||
|
|
||||||
|
t := &model.AuthToken{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)).
|
||||||
|
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create auth_token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindValid returns a non-used, non-expired token by hash + purpose.
|
||||||
|
func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
|
||||||
|
FROM auth_tokens
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND purpose = $2
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > now()
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
t := &model.AuthToken{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose).
|
||||||
|
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindValidForUser returns a non-used, non-expired token by user + hash +
|
||||||
|
// purpose. Used by the OTP login flow where the keyspace is small (6
|
||||||
|
// digits) — scoping by user_id prevents a global hash-collision lookup.
|
||||||
|
func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, tokenHash, purpose string) (*model.AuthToken, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
|
||||||
|
FROM auth_tokens
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND token_hash = $2
|
||||||
|
AND purpose = $3
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > now()
|
||||||
|
LIMIT 1`
|
||||||
|
|
||||||
|
t := &model.AuthToken{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, userID, tokenHash, purpose).
|
||||||
|
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume marks a token as used. After this call the token can never be
|
||||||
|
// honoured again, even if it has not yet expired.
|
||||||
|
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
|
||||||
|
const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, q, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("consume token: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidatePending marks all not-yet-used tokens for a user+purpose as used.
|
||||||
|
// Called when a fresh token is issued so old links are immediately dead.
|
||||||
|
func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error {
|
||||||
|
const q = `UPDATE auth_tokens SET used_at = now()
|
||||||
|
WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`
|
||||||
|
_, err := r.pg.Pool.Exec(ctx, q, userID, purpose)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalidate pending tokens: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+346
-13
@@ -12,7 +12,11 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -41,6 +45,21 @@ type Server struct {
|
|||||||
transcriptionH *handler.TranscriptionHandler
|
transcriptionH *handler.TranscriptionHandler
|
||||||
searchH *handler.SearchHandler
|
searchH *handler.SearchHandler
|
||||||
shortURLH *handler.ShortURLHandler
|
shortURLH *handler.ShortURLHandler
|
||||||
|
authH *handler.AuthHandler
|
||||||
|
oauthH *handler.OAuthHandler
|
||||||
|
userH *handler.UserHandler
|
||||||
|
postH *handler.PostHandler
|
||||||
|
bookmarkH *handler.BookmarkHandler
|
||||||
|
collectionH *handler.CollectionHandler
|
||||||
|
profileH *handler.ProfileHandler
|
||||||
|
relationH *handler.RelationshipHandler
|
||||||
|
notificationH *handler.NotificationHandler
|
||||||
|
feedbackH *handler.FeedbackHandler
|
||||||
|
statsH *handler.StatsHandler
|
||||||
|
|
||||||
|
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||||
|
authIssuer *auth.Issuer
|
||||||
|
userRepo *repository.UserRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Server with all routes registered.
|
// New creates a new Server with all routes registered.
|
||||||
@@ -86,10 +105,114 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
|||||||
// Connection failures degrade gracefully — the BFF stays up; only /share returns 503.
|
// Connection failures degrade gracefully — the BFF stays up; only /share returns 503.
|
||||||
s.initShortener(cfg, logger)
|
s.initShortener(cfg, logger)
|
||||||
|
|
||||||
|
// Auth: depends on Postgres being up. initShortener already opened the pool,
|
||||||
|
// so we reuse s.pg here. On a fresh DB the users + auth_tokens tables get
|
||||||
|
// created by the migration runner that initShortener already invoked.
|
||||||
|
s.initAuth(cfg, logger)
|
||||||
|
|
||||||
s.setupRouter()
|
s.setupRouter()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initAuth wires the user repo, JWT issuer, mailer, R2 client, and the auth
|
||||||
|
// + user handlers. If Postgres is unavailable, leaves the handlers nil and
|
||||||
|
// the routes 503-out gracefully.
|
||||||
|
func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||||
|
if s.pg == nil {
|
||||||
|
logger.Warn("auth: Postgres unavailable — /auth and /users will return 503")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo := repository.NewUserRepo(s.pg)
|
||||||
|
tokenRepo := repository.NewAuthTokenRepo(s.pg)
|
||||||
|
oauthRepo := repository.NewOAuthAccountRepo(s.pg)
|
||||||
|
|
||||||
|
r2, err := repository.NewR2(repository.R2Config{
|
||||||
|
AccountID: cfg.R2AccountID,
|
||||||
|
AccessKeyID: cfg.R2AccessKeyID,
|
||||||
|
SecretAccessKey: cfg.R2SecretAccessKey,
|
||||||
|
Bucket: cfg.R2Bucket,
|
||||||
|
PublicURL: cfg.R2PublicURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
|
||||||
|
}
|
||||||
|
if r2 == nil {
|
||||||
|
logger.Info("auth: R2 not configured — avatar presign endpoint will return 503")
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := auth.NewIssuer(cfg.JWTSecret, cfg.JWTExpiration)
|
||||||
|
mail := mailer.New(mailer.Config{
|
||||||
|
Host: cfg.SMTPHost,
|
||||||
|
Port: cfg.SMTPPort,
|
||||||
|
Username: cfg.SMTPUsername,
|
||||||
|
Password: cfg.SMTPPassword,
|
||||||
|
From: cfg.SMTPFrom,
|
||||||
|
}, logger)
|
||||||
|
|
||||||
|
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||||
|
userSvc := service.NewUserService(userRepo, r2)
|
||||||
|
|
||||||
|
// Social repositories + services share the same Postgres pool and R2 client.
|
||||||
|
postRepo := repository.NewPostRepo(s.pg)
|
||||||
|
bookmarkRepo := repository.NewBookmarkRepo(s.pg)
|
||||||
|
collectionRepo := repository.NewCollectionRepo(s.pg)
|
||||||
|
postSvc := service.NewPostService(postRepo, r2)
|
||||||
|
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
|
||||||
|
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
|
||||||
|
|
||||||
|
cookieOpts := auth.CookieOptions{Domain: cfg.CookieDomain, Secure: cfg.CookieSecure}
|
||||||
|
|
||||||
|
s.authIssuer = issuer
|
||||||
|
s.userRepo = userRepo
|
||||||
|
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||||
|
s.userH = handler.NewUserHandler(userSvc)
|
||||||
|
|
||||||
|
// OAuth — wired only when at least one provider is configured. The
|
||||||
|
// registry skips providers with missing creds, so partial configs (e.g.
|
||||||
|
// Google only, no Facebook) work out of the box.
|
||||||
|
providers := oauth.NewRegistry(oauth.Credentials{
|
||||||
|
GoogleClientID: cfg.GoogleOAuthClientID,
|
||||||
|
GoogleClientSecret: cfg.GoogleOAuthClientSecret,
|
||||||
|
FacebookClientID: cfg.FacebookOAuthClientID,
|
||||||
|
FacebookClientSecret: cfg.FacebookOAuthClientSecret,
|
||||||
|
})
|
||||||
|
if len(providers) > 0 {
|
||||||
|
s.oauthH = handler.NewOAuthHandler(
|
||||||
|
authSvc, cookieOpts, providers,
|
||||||
|
[]byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
|
||||||
|
}
|
||||||
|
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
||||||
|
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
||||||
|
|
||||||
|
// Social graph: follows / blocks / reports, plus the notification
|
||||||
|
// "plugin" seam (persists rows + emails today; websocket/push later).
|
||||||
|
// All share the same Postgres pool + mailer. Reports reuse the
|
||||||
|
// feedback admin recipients.
|
||||||
|
followRepo := repository.NewFollowRepo(s.pg)
|
||||||
|
blockRepo := repository.NewBlockRepo(s.pg)
|
||||||
|
reportRepo := repository.NewReportRepo(s.pg)
|
||||||
|
notifRepo := repository.NewNotificationRepo(s.pg)
|
||||||
|
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, logger, cfg.FrontendURL)
|
||||||
|
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
|
||||||
|
s.relationH = handler.NewRelationshipHandler(graphSvc)
|
||||||
|
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
||||||
|
s.postH = handler.NewPostHandler(postSvc, graphSvc)
|
||||||
|
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc)
|
||||||
|
|
||||||
|
// Feedback + stats — share Postgres with the rest of /api/v1 and
|
||||||
|
// the existing mailer instance.
|
||||||
|
feedbackRepo := repository.NewFeedbackRepo(s.pg)
|
||||||
|
statsRepo := repository.NewStatsRepo(s.pg)
|
||||||
|
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||||
|
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
||||||
|
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
|
||||||
|
}
|
||||||
|
|
||||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||||
// On connection failure it logs and leaves the components nil — the handler
|
// On connection failure it logs and leaves the components nil — the handler
|
||||||
// short-circuits to 503 so the rest of the BFF keeps working.
|
// short-circuits to 503 so the rest of the BFF keeps working.
|
||||||
@@ -152,6 +275,15 @@ func (s *Server) Close() {
|
|||||||
s.logger.Info("all connections closed")
|
s.logger.Info("all connections closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authUnavailable is the 503 fallback used when Postgres is down and the
|
||||||
|
// auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL
|
||||||
|
// returning a structured error rather than 404.
|
||||||
|
func (s *Server) authUnavailable(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = w.Write([]byte(`{"success":false,"error":{"code":503,"message":"auth service unavailable"}}`))
|
||||||
|
}
|
||||||
|
|
||||||
// injectBookCode is a middleware that sets "code" in chi's URL params
|
// injectBookCode is a middleware that sets "code" in chi's URL params
|
||||||
// for explicit sub-routes like /books/sb and /books/cc where {code}
|
// for explicit sub-routes like /books/sb and /books/cc where {code}
|
||||||
// isn't a URL param but a fixed path prefix.
|
// isn't a URL param but a fixed path prefix.
|
||||||
@@ -191,6 +323,19 @@ func (s *Server) setupRouter() {
|
|||||||
w.Write([]byte(`{"status":"ok"}`))
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Public ops snapshot — aggregate counts + recent feedback rendered
|
||||||
|
// as HTML. Lives outside /api/v1 because it returns HTML, not JSON.
|
||||||
|
// Currently unauthenticated by product decision; gate here if that
|
||||||
|
// ever needs to change.
|
||||||
|
if s.statsH != nil {
|
||||||
|
r.Get("/stats", s.statsH.Render)
|
||||||
|
// One-way per-row feedback actions — POST so a refresh of /stats
|
||||||
|
// can't replay them. Both live at the same public access level as
|
||||||
|
// /stats itself (gate them together if that ever changes).
|
||||||
|
r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback)
|
||||||
|
r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
|
||||||
|
}
|
||||||
|
|
||||||
// API v1
|
// API v1
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
// Books
|
// Books
|
||||||
@@ -367,20 +512,185 @@ func (s *Server) setupRouter() {
|
|||||||
// Search (Typesense)
|
// Search (Typesense)
|
||||||
r.Get("/search", s.searchH.Search)
|
r.Get("/search", s.searchH.Search)
|
||||||
|
|
||||||
// User routes (PostgreSQL)
|
// --- Auth ---
|
||||||
|
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||||
|
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||||
|
// require a session and live under the protected block below.
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
// TODO: register auth handlers (login, register, refresh)
|
if s.authH == nil {
|
||||||
})
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
r.Route("/users", func(r chi.Router) {
|
return
|
||||||
// TODO: register user handlers
|
}
|
||||||
})
|
r.Post("/signup", s.authH.SignUp)
|
||||||
r.Route("/collections", func(r chi.Router) {
|
r.Post("/login", s.authH.LogIn)
|
||||||
// TODO: register collection/bookmark handlers
|
r.Post("/verify-login-otp", s.authH.VerifyLoginOTP)
|
||||||
})
|
r.Post("/logout", s.authH.LogOut)
|
||||||
r.Route("/mentions", func(r chi.Router) {
|
r.Post("/forgot-password", s.authH.ForgotPassword)
|
||||||
// TODO: register mention handlers
|
r.Post("/reset-password", s.authH.ResetPassword)
|
||||||
|
r.Post("/verify-email", s.authH.VerifyEmail)
|
||||||
|
|
||||||
|
// OAuth (Google, Facebook, …). GET endpoints because the browser
|
||||||
|
// navigates directly to /start (top-level redirect) and the
|
||||||
|
// provider redirects back to /callback. Both flows set cookies
|
||||||
|
// and 302 — they never return JSON.
|
||||||
|
if s.oauthH != nil {
|
||||||
|
r.Get("/oauth/{provider}/start", s.oauthH.Start)
|
||||||
|
r.Get("/oauth/{provider}/callback", s.oauthH.Callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/me", s.authH.Me)
|
||||||
|
r.Get("/linked-providers", s.authH.LinkedProviders)
|
||||||
|
r.Post("/email", s.authH.SetEmail)
|
||||||
|
r.Post("/change-password", s.authH.ChangePassword)
|
||||||
|
r.Post("/resend-verification", s.authH.ResendVerification)
|
||||||
|
// Link-provider flow — requires the caller to be signed
|
||||||
|
// in; the state encodes their id so the callback links to
|
||||||
|
// THIS user instead of running the normal sign-in path.
|
||||||
|
// The callback itself is the shared /oauth/{provider}/callback
|
||||||
|
// above — the state's link_user_id field switches behaviour.
|
||||||
|
if s.oauthH != nil {
|
||||||
|
r.Get("/oauth/{provider}/link/start", s.oauthH.LinkStart)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// User profile — mixed public-read + protected writes.
|
||||||
|
r.Route("/users", func(r chi.Router) {
|
||||||
|
if s.userH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Public read — search by handle / name. Returns a
|
||||||
|
// sanitized payload (no email / role / plan / etc.).
|
||||||
|
r.Get("/search", s.userH.Search)
|
||||||
|
// Protected writes — profile edit, avatar presign,
|
||||||
|
// reading-prefs roaming.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Patch("/me", s.userH.UpdateProfile)
|
||||||
|
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
||||||
|
// Reading prefs roam with the account so theme/palette/
|
||||||
|
// font picks follow the user across devices. Body shape
|
||||||
|
// is owned by the frontend; the server treats it as an
|
||||||
|
// opaque JSON object.
|
||||||
|
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
||||||
|
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Social: posts ---
|
||||||
|
r.Route("/posts", func(r chi.Router) {
|
||||||
|
if s.postH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Public read — anonymous-OK but populates viewer_id when a
|
||||||
|
// session cookie is present, so the response can include
|
||||||
|
// per-viewer fields like `viewer_has_reshared`.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/{id}", s.postH.Get)
|
||||||
|
})
|
||||||
|
// Protected — create / delete / image presign / "my posts" /
|
||||||
|
// reshare actions.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/", s.postH.Create)
|
||||||
|
r.Delete("/{id}", s.postH.Delete)
|
||||||
|
r.Put("/{id}/images", s.postH.ReplaceImages)
|
||||||
|
r.Get("/mine", s.postH.ListMine)
|
||||||
|
r.Post("/image-presign", s.postH.ImagePresign)
|
||||||
|
// Reshare is auth-required: anonymous users can't claim
|
||||||
|
// to have reshared anything. The action is idempotent so
|
||||||
|
// double-clicks don't 4xx.
|
||||||
|
r.Post("/{id}/reshare", s.postH.Reshare)
|
||||||
|
r.Delete("/{id}/reshare", s.postH.Unreshare)
|
||||||
|
// Report a post (category + optional reason) → admin email.
|
||||||
|
r.Post("/{id}/report", s.postH.Report)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Social: bookmarks (all protected; bookmarks are private by design) ---
|
||||||
|
r.Route("/bookmarks", func(r chi.Router) {
|
||||||
|
if s.bookmarkH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/", s.bookmarkH.Create)
|
||||||
|
r.Get("/", s.bookmarkH.List)
|
||||||
|
r.Delete("/{id}", s.bookmarkH.Delete)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Social: collections ---
|
||||||
|
// The single-collection GET is the only route that may be hit
|
||||||
|
// anonymously (public collections); everything else needs auth.
|
||||||
|
// OptionalAuth on the GET lets us populate `viewer_has_reshared`
|
||||||
|
// on each post in the collection's carousel when a viewer is
|
||||||
|
// signed in, without rejecting anonymous traffic.
|
||||||
|
r.Route("/collections", func(r chi.Router) {
|
||||||
|
if s.collectionH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/{id}", s.collectionH.Get)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/", s.collectionH.Create)
|
||||||
|
r.Get("/", s.collectionH.ListMine)
|
||||||
|
r.Patch("/{id}", s.collectionH.Update)
|
||||||
|
r.Delete("/{id}", s.collectionH.Delete)
|
||||||
|
r.Post("/{id}/items", s.collectionH.AddItem)
|
||||||
|
r.Put("/{id}/items/order", s.collectionH.ReorderItems)
|
||||||
|
r.Delete("/{id}/items/{post_id}", s.collectionH.RemoveItem)
|
||||||
|
r.Get("/containing/{post_id}", s.collectionH.ContainingPost)
|
||||||
|
r.Post("/cover-presign", s.collectionH.CoverPresign)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Public profile + feed ---
|
||||||
|
// OptionalAuth populates viewer_id when a session cookie is
|
||||||
|
// present, so the feed can carry `viewer_has_reshared` on each
|
||||||
|
// post. Without a cookie the routes still respond — they just
|
||||||
|
// return the anonymous shape (all-false).
|
||||||
|
r.Route("/u", func(r chi.Router) {
|
||||||
|
if s.profileH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Public reads (anonymous-OK; OptionalAuth populates viewer_id
|
||||||
|
// so Get can include the viewer's relationship + the feed can
|
||||||
|
// carry viewer_has_reshared).
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/{handle}", s.profileH.Get)
|
||||||
|
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Relationship actions — follow/block/report. All require a
|
||||||
|
// signed-in viewer; each returns the recomputed relationship
|
||||||
|
// + the target's follower/following counts. Follows are
|
||||||
|
// request-based: POST /follow creates a PENDING request that
|
||||||
|
// the target later accepts or rejects.
|
||||||
|
if s.relationH != nil {
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/{handle}/follow", s.relationH.Follow)
|
||||||
|
r.Delete("/{handle}/follow", s.relationH.Unfollow)
|
||||||
|
r.Post("/{handle}/follow/accept", s.relationH.AcceptFollow)
|
||||||
|
r.Post("/{handle}/follow/reject", s.relationH.RejectFollow)
|
||||||
|
r.Delete("/{handle}/follower", s.relationH.RemoveFollower)
|
||||||
|
r.Post("/{handle}/block", s.relationH.Block)
|
||||||
|
r.Delete("/{handle}/block", s.relationH.Unblock)
|
||||||
|
r.Post("/{handle}/report", s.relationH.Report)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
// URL Shortener — Postgres-backed, Redis-queued.
|
// URL Shortener — Postgres-backed, Redis-queued.
|
||||||
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
||||||
r.Route("/share", func(r chi.Router) {
|
r.Route("/share", func(r chi.Router) {
|
||||||
@@ -398,9 +708,32 @@ func (s *Server) setupRouter() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Push Notifications
|
// --- Feedback ---
|
||||||
|
// OptionalAuth so authed callers' user_id is captured
|
||||||
|
// automatically (handler reads middleware.UserID), and
|
||||||
|
// anonymous callers are still accepted (they supply
|
||||||
|
// contact_email instead).
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
if s.feedbackH == nil {
|
||||||
|
r.HandleFunc("/feedback", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Post("/feedback", s.feedbackH.Submit)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notifications — the signed-in user's in-app feed + unread badge.
|
||||||
|
// Rows are persisted on every follow/accept event; websocket + push
|
||||||
|
// delivery will layer on later. All protected.
|
||||||
r.Route("/notifications", func(r chi.Router) {
|
r.Route("/notifications", func(r chi.Router) {
|
||||||
// TODO: register push notification handlers
|
if s.notificationH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/", s.notificationH.List)
|
||||||
|
r.Get("/unread-count", s.notificationH.UnreadCount)
|
||||||
|
r.Post("/read", s.notificationH.MarkAllRead)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,817 @@
|
|||||||
|
// Package service orchestrates the auth flows on top of the repositories.
|
||||||
|
//
|
||||||
|
// The handler layer is intentionally dumb: it parses the request, calls one
|
||||||
|
// AuthService method, and renders the result. All branching (already exists?
|
||||||
|
// wrong password? expired token?) lives here, behind a small set of named
|
||||||
|
// error sentinels.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors. Handlers map these to HTTP status codes.
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||||
|
ErrEmailAlreadyTaken = errors.New("email already registered")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
|
||||||
|
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||||
|
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||||
|
ErrHandleTaken = errors.New("handle already in use")
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthService bundles every flow that mutates auth state.
|
||||||
|
type AuthService struct {
|
||||||
|
users *repository.UserRepo
|
||||||
|
tokens *repository.AuthTokenRepo
|
||||||
|
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
|
||||||
|
jwt *auth.Issuer
|
||||||
|
mail mailer.Mailer
|
||||||
|
logger *zap.Logger
|
||||||
|
frontend string // FrontendURL — used to build reset / verify links
|
||||||
|
|
||||||
|
// TTLs — surfaced as fields so tests can shrink them.
|
||||||
|
PasswordResetTTL time.Duration
|
||||||
|
EmailVerifyTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
||||||
|
// the OAuth flows aren't wired (handler then returns 503).
|
||||||
|
func NewAuthService(
|
||||||
|
users *repository.UserRepo,
|
||||||
|
tokens *repository.AuthTokenRepo,
|
||||||
|
oauthRepo *repository.OAuthAccountRepo,
|
||||||
|
jwt *auth.Issuer,
|
||||||
|
mail mailer.Mailer,
|
||||||
|
logger *zap.Logger,
|
||||||
|
frontendURL string,
|
||||||
|
) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
users: users,
|
||||||
|
tokens: tokens,
|
||||||
|
oauth: oauthRepo,
|
||||||
|
jwt: jwt,
|
||||||
|
mail: mail,
|
||||||
|
logger: logger,
|
||||||
|
frontend: strings.TrimRight(frontendURL, "/"),
|
||||||
|
PasswordResetTTL: 1 * time.Hour,
|
||||||
|
EmailVerifyTTL: 24 * time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureCountry records the account's country only if it isn't already
|
||||||
|
// set. Best-effort: a blank code is a no-op, and DB errors are logged but
|
||||||
|
// swallowed so the caller (a post-signup goroutine) never fails on it.
|
||||||
|
func (s *AuthService) CaptureCountry(ctx context.Context, userID, country string) {
|
||||||
|
if strings.TrimSpace(country) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.users.SetCountryIfEmpty(ctx, userID, country); err != nil {
|
||||||
|
s.logger.Warn("capture signup country failed",
|
||||||
|
zap.String("user_id", userID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResult is the value handlers return on signup/login. Token is the raw
|
||||||
|
// JWT the handler will plant in the response cookie; the User is the public
|
||||||
|
// profile to ship in the response body.
|
||||||
|
type AuthResult struct {
|
||||||
|
User *model.User
|
||||||
|
Token string
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignUp creates a new user, issues a session JWT, and fires off a
|
||||||
|
// verification email (best-effort — failure to send does not block signup,
|
||||||
|
// since the user can hit resend later).
|
||||||
|
func (s *AuthService) SignUp(ctx context.Context, email, password, name string) (*AuthResult, error) {
|
||||||
|
if len(password) < 8 {
|
||||||
|
return nil, ErrWeakPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a handle and retry up to 8 times if it collides with an
|
||||||
|
// existing user. The slug seed is derived from the email's local-part,
|
||||||
|
// which is the closest thing to a free-form identifier the user gave us.
|
||||||
|
base := slugForHandle(email)
|
||||||
|
var u *model.User
|
||||||
|
for attempt := 0; attempt < 8; attempt++ {
|
||||||
|
handle := base
|
||||||
|
if attempt > 0 {
|
||||||
|
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||||
|
}
|
||||||
|
u, err = s.users.Create(ctx, email, string(hash), strings.TrimSpace(name), handle)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrEmailTaken) {
|
||||||
|
return nil, ErrEmailAlreadyTaken
|
||||||
|
}
|
||||||
|
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
||||||
|
// Log and keep going — signup itself succeeded.
|
||||||
|
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.issueSession(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResult is the two-state outcome of LogIn:
|
||||||
|
//
|
||||||
|
// - `Auth != nil` — credentials verified AND email is fine. Caller
|
||||||
|
// sets the session cookie and returns the user.
|
||||||
|
// - `OTPRequired == true` — credentials verified but the account has been
|
||||||
|
// unverified for more than 30 days. An OTP has been emailed; the caller
|
||||||
|
// must collect it from the user and call VerifyLoginOTP to complete the
|
||||||
|
// sign-in. No session cookie is set yet.
|
||||||
|
//
|
||||||
|
// One of the two is always set on a non-error return.
|
||||||
|
type LoginResult struct {
|
||||||
|
Auth *AuthResult
|
||||||
|
OTPRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleUnverifiedAge is how long an account can stay unverified before
|
||||||
|
// every subsequent email/password login is gated on an OTP. Anything
|
||||||
|
// shorter and a normal user who simply hasn't checked their inbox yet
|
||||||
|
// would hit the gate; longer and abandoned-but-not-verified accounts
|
||||||
|
// remain a forever-credential. 30 days is the documented requirement.
|
||||||
|
const staleUnverifiedAge = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// LogIn authenticates a user by email + password. The error returned for an
|
||||||
|
// unknown email is intentionally identical to a wrong password — never leak
|
||||||
|
// which one was wrong. May return early with OTPRequired=true; see LoginResult.
|
||||||
|
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*LoginResult, error) {
|
||||||
|
u, err := s.users.FindByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrUserNotFound) {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// OAuth-only accounts have no password hash. Treat them as a credential
|
||||||
|
// mismatch so we don't reveal that an OAuth user exists for this email.
|
||||||
|
if u.PasswordHash == nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTP gate — unverified email AND account is older than the grace
|
||||||
|
// window. Mint an OTP and signal the caller to collect it. We do NOT
|
||||||
|
// issue a session here.
|
||||||
|
if u.EmailVerifiedAt == nil && time.Since(u.CreatedAt) > staleUnverifiedAge {
|
||||||
|
if u.Email == nil || *u.Email == "" {
|
||||||
|
// No email to send to → fall through to "invalid credentials"
|
||||||
|
// rather than expose this corner case.
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := s.sendLoginOTP(ctx, u); err != nil {
|
||||||
|
s.logger.Warn("login otp send failed",
|
||||||
|
zap.String("user", u.ID), zap.Error(err))
|
||||||
|
return nil, fmt.Errorf("send login otp: %w", err)
|
||||||
|
}
|
||||||
|
return &LoginResult{OTPRequired: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.issueSession(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &LoginResult{Auth: res}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyLoginOTP completes a login that was paused by the stale-unverified
|
||||||
|
// OTP gate. Re-checks the password (so an attacker who steals an OTP email
|
||||||
|
// still can't sign in without the password), validates the OTP, flips
|
||||||
|
// email_verified_at to now(), and issues a fresh session.
|
||||||
|
func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp string) (*AuthResult, error) {
|
||||||
|
u, err := s.users.FindByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if u.PasswordHash == nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
otpHash := hashToken(otp)
|
||||||
|
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidOrExpiredLink
|
||||||
|
}
|
||||||
|
if err := s.tokens.Consume(ctx, tok.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u2, err := s.users.FindByID(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.issueSession(u2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendLoginOTP mints a fresh 6-digit code, invalidates any older pending
|
||||||
|
// codes, stores the SHA-256 hash, and emails the raw code to the user.
|
||||||
|
func (s *AuthService) sendLoginOTP(ctx context.Context, u *model.User) error {
|
||||||
|
if u.Email == nil || *u.Email == "" {
|
||||||
|
return errors.New("no email on file")
|
||||||
|
}
|
||||||
|
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeLoginOTP)
|
||||||
|
|
||||||
|
otp, hash, err := newOTP()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeLoginOTP, hash, 10*time.Minute); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := loginOTPEmail(u.Name, otp)
|
||||||
|
return s.mail.Send(*u.Email, "Your login code — Prabhupāda Says", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOTP returns a freshly-generated 6-digit code + its SHA-256 hex hash.
|
||||||
|
// Modulo bias on a uniform 32-bit source is negligible for the 1M keyspace
|
||||||
|
// (10⁶ / 2³² ≈ 4 parts in 10⁴) and irrelevant against rate-limited online
|
||||||
|
// guessing, which is the only attack model we care about for this size.
|
||||||
|
func newOTP() (raw, hash string, err error) {
|
||||||
|
var b [4]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
n := binary.BigEndian.Uint32(b[:]) % 1_000_000
|
||||||
|
raw = fmt.Sprintf("%06d", n)
|
||||||
|
hash = hashToken(raw)
|
||||||
|
return raw, hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthProfile is the provider-agnostic profile shape consumed by
|
||||||
|
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
|
||||||
|
// layer doesn't import the OAuth package (which would create a layering
|
||||||
|
// cycle once handlers compose both).
|
||||||
|
type OAuthProfile struct {
|
||||||
|
Provider string
|
||||||
|
ProviderUserID string
|
||||||
|
Email string
|
||||||
|
EmailVerified bool
|
||||||
|
Name string
|
||||||
|
AvatarURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
|
||||||
|
// startup). Handler maps to 503.
|
||||||
|
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
|
||||||
|
|
||||||
|
// ErrOAuthAccountClaimed is returned by LinkOAuthToCurrentUser when the
|
||||||
|
// provider identity is already linked to a different user. We refuse to
|
||||||
|
// move the linkage — the rightful owner of the OAuth account at the
|
||||||
|
// provider can sign in via the normal flow.
|
||||||
|
var ErrOAuthAccountClaimed = errors.New("oauth account already linked to another user")
|
||||||
|
|
||||||
|
// LinkOAuthToCurrentUser ties an OAuth provider identity to a specific
|
||||||
|
// already-authenticated user (settings → Connect flow). Refuses to move
|
||||||
|
// the linkage if some other user already claims it.
|
||||||
|
//
|
||||||
|
// Email policy: the user's primary email is NEVER touched by this call.
|
||||||
|
// The provider's email is stored on the oauth_accounts row only for
|
||||||
|
// reference (display in the future, audit). If the user wants to change
|
||||||
|
// their primary email they have to do it explicitly via SetEmail.
|
||||||
|
func (s *AuthService) LinkOAuthToCurrentUser(ctx context.Context, userID string, prof OAuthProfile) error {
|
||||||
|
if s.oauth == nil {
|
||||||
|
return ErrOAuthUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this (provider, sub) is already linked, the only valid case is
|
||||||
|
// that it's linked to the SAME user we're trying to link to. Any other
|
||||||
|
// user owning this linkage means the OAuth account was already claimed
|
||||||
|
// (typically the user mis-clicked or it's a different person on a
|
||||||
|
// shared device) — refuse rather than silently steal it.
|
||||||
|
if existing, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||||
|
if existing.UserID != userID {
|
||||||
|
return ErrOAuthAccountClaimed
|
||||||
|
}
|
||||||
|
// Same user — idempotent: refresh the denormalised profile fields
|
||||||
|
// and return success.
|
||||||
|
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
return err
|
||||||
|
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh link.
|
||||||
|
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
|
||||||
|
//
|
||||||
|
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
|
||||||
|
// 2. Provider gave us an email AND an existing user has the same email →
|
||||||
|
// link this provider to them, log in.
|
||||||
|
// 3. Otherwise create a new user (no password, email_verified iff we have
|
||||||
|
// an email) and link.
|
||||||
|
//
|
||||||
|
// When the provider returns no email (Facebook accounts with no verified
|
||||||
|
// email on file, accounts that declined the email scope, etc.) we skip
|
||||||
|
// the merge step and create the user with email=NULL. They can add an
|
||||||
|
// email later via PATCH /users/me — the frontend prompts them.
|
||||||
|
//
|
||||||
|
// When we DO have an email, every successful OAuth round-trip is treated
|
||||||
|
// as proof the user controls it — Google + Facebook both verify email
|
||||||
|
// ownership before returning it, so we mark the email as verified.
|
||||||
|
//
|
||||||
|
// In every case the resulting user gets a fresh JWT just like LogIn.
|
||||||
|
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
|
||||||
|
if s.oauth == nil {
|
||||||
|
return nil, ErrOAuthUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Known linkage.
|
||||||
|
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||||
|
u, err := s.users.FindByID(ctx, a.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Refresh denormalised fields on the linkage row (display name /
|
||||||
|
// avatar may have changed at the provider).
|
||||||
|
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
if prof.Email != "" {
|
||||||
|
s.ensureEmailVerified(ctx, u)
|
||||||
|
}
|
||||||
|
return s.issueSession(u)
|
||||||
|
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Existing user with the same email → link.
|
||||||
|
// Only attempt this when the provider actually gave us an email. With
|
||||||
|
// no email there's no merge key — we just create a fresh user below.
|
||||||
|
if prof.Email != "" {
|
||||||
|
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
|
||||||
|
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ensureEmailVerified(ctx, u)
|
||||||
|
return s.issueSession(u)
|
||||||
|
} else if !errors.Is(err, repository.ErrUserNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Brand new user. Reuse the handle-collision retry loop.
|
||||||
|
displayName := prof.Name
|
||||||
|
if displayName == "" && prof.Email != "" {
|
||||||
|
displayName = strings.SplitN(prof.Email, "@", 2)[0]
|
||||||
|
}
|
||||||
|
if displayName == "" {
|
||||||
|
// No name AND no email — fall back to the provider name so the user
|
||||||
|
// at least has a non-empty display name they can rename later.
|
||||||
|
displayName = strings.Title(prof.Provider) + " user" //nolint:staticcheck // legacy Title is fine for ASCII labels
|
||||||
|
}
|
||||||
|
base := slugForHandle(prof.Email)
|
||||||
|
if base == "user" && prof.ProviderUserID != "" {
|
||||||
|
// slugForHandle returns "user" for empty input — prefer something
|
||||||
|
// less collision-prone for OAuth-no-email signups.
|
||||||
|
base = "user-" + safeSlugSuffix(prof.ProviderUserID)
|
||||||
|
}
|
||||||
|
emailVerified := prof.Email != "" // can only be verified if we have an email
|
||||||
|
var u *model.User
|
||||||
|
var err error
|
||||||
|
for attempt := 0; attempt < 8; attempt++ {
|
||||||
|
handle := base
|
||||||
|
if attempt > 0 {
|
||||||
|
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||||
|
}
|
||||||
|
u, err = s.users.CreateOAuth(ctx, optStr(prof.Email), displayName, handle, optStr(prof.AvatarURL), emailVerified)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrEmailTaken) {
|
||||||
|
// Race: someone else just claimed this email. Fall back to step 2.
|
||||||
|
// Only reachable when prof.Email != "" (NULL emails never collide).
|
||||||
|
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
|
||||||
|
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
s.ensureEmailVerified(ctx, u2)
|
||||||
|
return s.issueSession(u2)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.issueSession(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeSlugSuffix reduces an arbitrary provider id to a short ASCII tail
|
||||||
|
// suitable as part of a handle. Provider ids are typically all-digit
|
||||||
|
// (Facebook) or short ASCII (Google sub), so a trailing 8-char window is
|
||||||
|
// plenty and avoids handles getting silly-long.
|
||||||
|
func safeSlugSuffix(s string) string {
|
||||||
|
out := strings.Map(func(r rune) rune {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
return r
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
return r + ('a' - 'A')
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}, s)
|
||||||
|
if len(out) > 8 {
|
||||||
|
out = out[len(out)-8:]
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
return randomHandleSuffix()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// optStr returns nil for empty input, &s otherwise — used to populate
|
||||||
|
// nullable text columns from possibly-empty profile fields.
|
||||||
|
func optStr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedProviders returns the OAuth provider names already linked to the
|
||||||
|
// caller's account ("google", "facebook"). Used by the settings UI to flip
|
||||||
|
// the per-provider rows between "Connect" and "Connected".
|
||||||
|
func (s *AuthService) LinkedProviders(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
if s.oauth == nil {
|
||||||
|
// OAuth repo not wired (no Postgres). No-op rather than erroring —
|
||||||
|
// settings just shows everything as un-connected.
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return s.oauth.ListProvidersForUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword sets the caller's password, in two modes:
|
||||||
|
//
|
||||||
|
// 1. Account HAS a password — caller must supply the current one. We
|
||||||
|
// verify with bcrypt before replacing, defending against an attacker
|
||||||
|
// who got hold of the session cookie but doesn't know the password.
|
||||||
|
// 2. Account has NO password yet (OAuth-only signup) — caller can set
|
||||||
|
// one without proving anything beyond their existing session. There's
|
||||||
|
// no current password to verify against, and the session already
|
||||||
|
// grants them everything the new password would, so requiring an
|
||||||
|
// email round-trip here would only be friction.
|
||||||
|
//
|
||||||
|
// In both cases token_version is bumped (invalidating every JWT ever
|
||||||
|
// issued to this user) and a fresh session is re-issued so the calling
|
||||||
|
// device stays logged in while every other device is signed out.
|
||||||
|
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) (*AuthResult, error) {
|
||||||
|
if len(newPassword) < 8 {
|
||||||
|
return nil, ErrWeakPassword
|
||||||
|
}
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if u.PasswordHash != nil {
|
||||||
|
// Account has a password — current must match before we'll replace it.
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(currentPassword)); err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If u.PasswordHash == nil we skip the verification: setting an initial
|
||||||
|
// password for an OAuth-only account doesn't require proving anything
|
||||||
|
// beyond the active session.
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.users.UpdatePassword(ctx, userID, string(hash)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Re-fetch to pick up the new token_version so the freshly-issued JWT
|
||||||
|
// has the right "tv" claim and the cookie set after this call keeps
|
||||||
|
// the user logged in on this device.
|
||||||
|
u2, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.issueSession(u2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEmail fills in (or replaces) the caller's email address. Used by the
|
||||||
|
// "add your email" flow for OAuth users who signed up without one, and by
|
||||||
|
// any user who later wants to change their address.
|
||||||
|
//
|
||||||
|
// The address is stored as unverified — a verification link is sent to it
|
||||||
|
// immediately. Returns ErrEmailAlreadyTaken if another row already owns it.
|
||||||
|
func (s *AuthService) SetEmail(ctx context.Context, userID, email string) (*model.User, error) {
|
||||||
|
u, err := s.users.SetEmail(ctx, userID, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrEmailTaken) {
|
||||||
|
return nil, ErrEmailAlreadyTaken
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
||||||
|
// Verification email failure shouldn't block the set — the user can
|
||||||
|
// always trigger a resend.
|
||||||
|
s.logger.Warn("verification email failed after SetEmail",
|
||||||
|
zap.String("user", u.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureEmailVerified flips email_verified_at to now() on the given user
|
||||||
|
// if it's not already set. Called from every SignInWithOAuth branch so a
|
||||||
|
// successful OAuth round-trip immediately satisfies email verification
|
||||||
|
// for an existing-but-unverified account. Mutates u in place so callers
|
||||||
|
// see the updated timestamp without re-fetching.
|
||||||
|
func (s *AuthService) ensureEmailVerified(ctx context.Context, u *model.User) {
|
||||||
|
if u == nil || u.EmailVerifiedAt != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
|
||||||
|
s.logger.Warn("mark email verified after oauth", zap.String("user", u.ID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
u.EmailVerifiedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me looks up a user by id and re-verifies the JWT's token_version against
|
||||||
|
// the stored value. Mismatch means the user has logged-out-everywhere or
|
||||||
|
// changed password since this JWT was issued → reject.
|
||||||
|
func (s *AuthService) Me(ctx context.Context, userID string, tokenVersion int) (*model.User, error) {
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if u.TokenVersion != tokenVersion {
|
||||||
|
return nil, auth.ErrInvalidToken
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestPasswordReset always returns nil to callers — we don't want to
|
||||||
|
// reveal whether the email is registered. If the email exists we mint a
|
||||||
|
// token and email a link; otherwise we silently no-op.
|
||||||
|
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
||||||
|
u, err := s.users.FindByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrUserNotFound) {
|
||||||
|
return nil // do not reveal
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate any previous reset tokens for this user.
|
||||||
|
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposePasswordReset)
|
||||||
|
|
||||||
|
raw, hash, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.tokens.Create(ctx, u.ID, model.PurposePasswordReset, hash, s.PasswordResetTTL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth-only users may have no email on file — skip the send silently
|
||||||
|
// (the caller already cannot tell whether the email was registered, so
|
||||||
|
// "no email to send to" surfaces the same way).
|
||||||
|
if u.Email == nil || *u.Email == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw)
|
||||||
|
body := passwordResetEmail(u.Name, link)
|
||||||
|
if err := s.mail.Send(*u.Email, "Reset your Prabhupāda Says password", body); err != nil {
|
||||||
|
s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword consumes a reset token and sets a new password.
|
||||||
|
// On success the user's token_version is bumped, invalidating every
|
||||||
|
// outstanding JWT (so a stolen token can't be used after the legit owner
|
||||||
|
// changes their password).
|
||||||
|
func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
|
||||||
|
if len(newPassword) < 8 {
|
||||||
|
return ErrWeakPassword
|
||||||
|
}
|
||||||
|
hash := hashToken(rawToken)
|
||||||
|
|
||||||
|
t, err := s.tokens.FindValid(ctx, hash, model.PurposePasswordReset)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidOrExpiredLink
|
||||||
|
}
|
||||||
|
|
||||||
|
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.users.UpdatePassword(ctx, t.UserID, string(bcryptHash)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.tokens.Consume(ctx, t.ID); err != nil {
|
||||||
|
s.logger.Warn("consume reset token", zap.String("token_id", t.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail consumes an email-verification token and flips the user's
|
||||||
|
// email_verified_at timestamp.
|
||||||
|
func (s *AuthService) VerifyEmail(ctx context.Context, rawToken string) error {
|
||||||
|
hash := hashToken(rawToken)
|
||||||
|
t, err := s.tokens.FindValid(ctx, hash, model.PurposeEmailVerification)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidOrExpiredLink
|
||||||
|
}
|
||||||
|
if err := s.users.MarkEmailVerified(ctx, t.UserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.tokens.Consume(ctx, t.ID); err != nil {
|
||||||
|
s.logger.Warn("consume verify token", zap.String("token_id", t.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendVerification re-fires the verification email for the currently-logged-in
|
||||||
|
// user. No-ops with no error if the user is already verified.
|
||||||
|
func (s *AuthService) ResendVerification(ctx context.Context, userID string) error {
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
if u.EmailVerifiedAt != nil {
|
||||||
|
return ErrEmailAlreadyVerified
|
||||||
|
}
|
||||||
|
return s.sendVerificationEmail(ctx, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internal helpers ---
|
||||||
|
|
||||||
|
func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
|
||||||
|
tok, exp, err := s.jwt.Issue(u.ID, u.TokenVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &AuthResult{User: u, Token: tok, Expires: exp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error {
|
||||||
|
// Users with no email on file (OAuth signups where the provider didn't
|
||||||
|
// return one) have nothing to send to. The caller is expected to gate
|
||||||
|
// on this, but defensively no-op here too.
|
||||||
|
if u.Email == nil || *u.Email == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification)
|
||||||
|
|
||||||
|
raw, hash, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeEmailVerification, hash, s.EmailVerifyTTL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw)
|
||||||
|
body := verifyEmailMessage(u.Name, link)
|
||||||
|
return s.mail.Send(*u.Email, "Verify your email — Prabhupāda Says", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newToken returns (rawHex, sha256HexHash). The raw value goes in the
|
||||||
|
// emailed link; only the hash is stored in the DB.
|
||||||
|
func newToken() (raw, hash string, err error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
raw = hex.EncodeToString(b)
|
||||||
|
hash = hashToken(raw)
|
||||||
|
return raw, hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// slugForHandle turns the local-part of an email into a kebab-case handle.
|
||||||
|
// Falls back to "user" for fully-stripped inputs so we always have a seed
|
||||||
|
// the retry loop can append a suffix to.
|
||||||
|
func slugForHandle(email string) string {
|
||||||
|
at := strings.IndexByte(email, '@')
|
||||||
|
local := email
|
||||||
|
if at >= 0 {
|
||||||
|
local = email[:at]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(local))
|
||||||
|
for _, r := range strings.ToLower(local) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '.', r == '_', r == '-':
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
if out == "" {
|
||||||
|
out = "user"
|
||||||
|
}
|
||||||
|
if len(out) > 32 {
|
||||||
|
out = out[:32]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomHandleSuffix returns 5 hex characters (~20 bits of entropy) — plenty
|
||||||
|
// to disambiguate names that already exist in the user base.
|
||||||
|
func randomHandleSuffix() string {
|
||||||
|
b := make([]byte, 3)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(raw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- email bodies (intentionally minimal HTML; auth mails should be plain) ---
|
||||||
|
|
||||||
|
func passwordResetEmail(name, link string) string {
|
||||||
|
return fmt.Sprintf(`<p>Hari Bol %s,</p>
|
||||||
|
<p>You (or someone using your email) asked to reset your password on
|
||||||
|
<strong>Prabhupāda Says</strong>. Click the link below to set a new one:</p>
|
||||||
|
<p><a href="%s">%s</a></p>
|
||||||
|
<p>This link expires in 1 hour. If you didn't request a reset, just ignore
|
||||||
|
this email — your password stays the same.</p>`, htmlEscape(name), link, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyEmailMessage(name, link string) string {
|
||||||
|
return fmt.Sprintf(`<p>Hari Bol %s,</p>
|
||||||
|
<p>Welcome to <strong>Prabhupāda Says</strong>. Please confirm your email by
|
||||||
|
clicking the link below:</p>
|
||||||
|
<p><a href="%s">%s</a></p>
|
||||||
|
<p>This link expires in 24 hours. You can request a new one from the banner
|
||||||
|
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginOTPEmail(name, otp string) string {
|
||||||
|
return fmt.Sprintf(`<p>Hari Bol %s,</p>
|
||||||
|
<p>Your account on <strong>Prabhupāda Says</strong> has been unverified for a
|
||||||
|
while, so we need a quick one-time code before signing you in:</p>
|
||||||
|
<p style="font-size:1.5rem;font-weight:600;letter-spacing:0.25em;margin:1.25rem 0;">%s</p>
|
||||||
|
<p>This code expires in 10 minutes. If you didn't try to sign in, you can
|
||||||
|
safely ignore this email.</p>`, htmlEscape(name), otp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func htmlEscape(s string) string {
|
||||||
|
r := strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
"\"", """,
|
||||||
|
)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// Package service — feedback flow. Sits between the feedback handler
|
||||||
|
// and FeedbackRepo: validates the submission, writes the row, then
|
||||||
|
// fans an HTML notification email out to every address listed in
|
||||||
|
// FEEDBACK_ADMIN_EMAILS. The email is fire-and-forget — transient
|
||||||
|
// send failures are logged and don't fail the request, because the
|
||||||
|
// row is already in the DB and visible on /stats.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FeedbackService struct {
|
||||||
|
repo *repository.FeedbackRepo
|
||||||
|
mail mailer.Mailer
|
||||||
|
admins []string
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
|
||||||
|
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed categories — kept lowercase in storage so dashboards group
|
||||||
|
// cleanly. The frontend dropdown sends one of these exact strings.
|
||||||
|
var feedbackCategories = map[string]bool{
|
||||||
|
"bug": true,
|
||||||
|
"feature": true,
|
||||||
|
"general": true,
|
||||||
|
"other": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit persists a feedback row and fires the admin email.
|
||||||
|
//
|
||||||
|
// userID is nil for anonymous submissions; contactEmail is the
|
||||||
|
// optional reply-to in that case. For authed users we ignore whatever
|
||||||
|
// the form sent and read the canonical email off the joined user row
|
||||||
|
// when assembling the notification — that keeps the reply path
|
||||||
|
// trustworthy (the form can't lie about who's writing).
|
||||||
|
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
|
||||||
|
category = strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if !feedbackCategories[category] {
|
||||||
|
return nil, errors.New("invalid category")
|
||||||
|
}
|
||||||
|
message = strings.TrimSpace(message)
|
||||||
|
if message == "" {
|
||||||
|
return nil, errors.New("message is required")
|
||||||
|
}
|
||||||
|
if len(message) > 5000 {
|
||||||
|
return nil, errors.New("message too long (max 5000)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == nil {
|
||||||
|
if contactEmail == nil || strings.TrimSpace(*contactEmail) == "" {
|
||||||
|
return nil, errors.New("contact_email is required when not logged in")
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(*contactEmail)
|
||||||
|
contactEmail = &trimmed
|
||||||
|
} else {
|
||||||
|
// Authed: ignore client-supplied contact_email; user row owns it.
|
||||||
|
contactEmail = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.repo.Create(ctx, userID, category, message, contactEmail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate the joined author fields for the email template. Failing
|
||||||
|
// the lookup isn't worth failing the request — we fall back to
|
||||||
|
// "anonymous" rather than 500ing on the user.
|
||||||
|
if userID != nil {
|
||||||
|
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
|
||||||
|
f = full
|
||||||
|
} else {
|
||||||
|
s.logger.Warn("feedback hydrate failed; sending email without author info",
|
||||||
|
zap.String("feedback_id", f.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.notifyAdmins(f)
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrAlreadyResolved is returned by Resolve when the row was already
|
||||||
|
// closed (or doesn't exist). The handler maps it to a benign redirect
|
||||||
|
// rather than an error — the end state the admin wanted is already true.
|
||||||
|
var ErrAlreadyResolved = errors.New("feedback already resolved")
|
||||||
|
|
||||||
|
// ErrAlreadyAcknowledged is the acknowledge-path counterpart. Same
|
||||||
|
// benign-redirect treatment in the handler.
|
||||||
|
var ErrAlreadyAcknowledged = errors.New("feedback already acknowledged")
|
||||||
|
|
||||||
|
// Acknowledge marks a feedback row acknowledged (one-way) and, if a
|
||||||
|
// recipient address is known, emails the author that the team has seen
|
||||||
|
// their feedback and — if it needs work — the devs have started, with a
|
||||||
|
// second email promised on completion.
|
||||||
|
//
|
||||||
|
// Mirrors Resolve: MarkAcknowledged's `acknowledged_at IS NULL` guard
|
||||||
|
// guarantees the email fires at most once; the email is fire-and-forget.
|
||||||
|
func (s *FeedbackService) Acknowledge(ctx context.Context, id string) error {
|
||||||
|
acked, err := s.repo.MarkAcknowledged(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !acked {
|
||||||
|
return ErrAlreadyAcknowledged
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.repo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("feedback acknowledged but hydrate failed; skipping email",
|
||||||
|
zap.String("feedback_id", id), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
to := feedbackReplyTo(f)
|
||||||
|
if to == "" {
|
||||||
|
s.logger.Info("feedback acknowledged; no recipient address, skipping email",
|
||||||
|
zap.String("feedback_id", id))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := renderFeedbackAcknowledgedEmail(f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("render feedback-acknowledged email failed", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.mail.Send(to, "We've received your feedback — Prabhupāda Says", body); err != nil {
|
||||||
|
s.logger.Warn("feedback-acknowledged email send failed",
|
||||||
|
zap.String("to", to), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve marks a feedback row resolved (one-way) and, if a recipient
|
||||||
|
// address is known, sends the author a Vaishnava-styled thank-you note.
|
||||||
|
//
|
||||||
|
// The DB update is the source of truth: MarkResolved's `resolved_at IS
|
||||||
|
// NULL` guard guarantees the email fires at most once even under
|
||||||
|
// concurrent button presses. The email is fire-and-forget — a send
|
||||||
|
// failure logs but doesn't un-resolve the row.
|
||||||
|
func (s *FeedbackService) Resolve(ctx context.Context, id string) error {
|
||||||
|
resolved, err := s.repo.MarkResolved(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resolved {
|
||||||
|
return ErrAlreadyResolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read with author joins so we know where to send the thank-you.
|
||||||
|
f, err := s.repo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
// Row IS resolved; we just can't assemble the email. Log and
|
||||||
|
// succeed — the resolve itself stuck.
|
||||||
|
s.logger.Warn("feedback resolved but hydrate failed; skipping thank-you email",
|
||||||
|
zap.String("feedback_id", id), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
to := feedbackReplyTo(f)
|
||||||
|
if to == "" {
|
||||||
|
// No email on file (anonymous submitter who left no contact, or
|
||||||
|
// a since-deleted account). Nothing to send — that's fine.
|
||||||
|
s.logger.Info("feedback resolved; no recipient address, skipping thank-you email",
|
||||||
|
zap.String("feedback_id", id))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := renderFeedbackResolvedEmail(f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("render feedback-resolved email failed", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.mail.Send(to, "Your feedback has been addressed — Prabhupāda Says", body); err != nil {
|
||||||
|
s.logger.Warn("feedback-resolved email send failed",
|
||||||
|
zap.String("to", to), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedbackReplyTo picks the address to thank: the authed submitter's
|
||||||
|
// account email if present, else the anonymous contact_email, else "".
|
||||||
|
func feedbackReplyTo(f *model.Feedback) string {
|
||||||
|
if f.AuthorEmail != nil && strings.TrimSpace(*f.AuthorEmail) != "" {
|
||||||
|
return strings.TrimSpace(*f.AuthorEmail)
|
||||||
|
}
|
||||||
|
if f.ContactEmail != nil && strings.TrimSpace(*f.ContactEmail) != "" {
|
||||||
|
return strings.TrimSpace(*f.ContactEmail)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyAdmins fires one HTML email per configured admin. Each
|
||||||
|
// failure logs and falls through — the row is persisted so a missed
|
||||||
|
// email is annoying but not data-losing.
|
||||||
|
func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
|
||||||
|
if len(s.admins) == 0 {
|
||||||
|
s.logger.Info("feedback received but no admin recipients configured",
|
||||||
|
zap.String("feedback_id", f.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := renderFeedbackEmail(f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("render feedback email failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60))
|
||||||
|
for _, to := range s.admins {
|
||||||
|
if err := s.mail.Send(to, subject, body); err != nil {
|
||||||
|
s.logger.Warn("feedback email send failed",
|
||||||
|
zap.String("to", to), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarize(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedbackEmailTpl = template.Must(template.New("feedback").Parse(`<p><strong>New feedback received</strong></p>
|
||||||
|
<p><strong>Category:</strong> {{.Category}}<br>
|
||||||
|
<strong>Submitted:</strong> {{.CreatedAt}}<br>
|
||||||
|
<strong>From:</strong> {{.From}}</p>
|
||||||
|
<p><strong>Message:</strong></p>
|
||||||
|
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Message}}</pre>
|
||||||
|
<p style="color:#999;font-size:12px">Feedback ID: {{.ID}}</p>`))
|
||||||
|
|
||||||
|
func renderFeedbackEmail(f *model.Feedback) (string, error) {
|
||||||
|
var from string
|
||||||
|
switch {
|
||||||
|
case f.AuthorHandle != nil && *f.AuthorHandle != "":
|
||||||
|
from = fmt.Sprintf("%s (@%s)", derefOr(f.AuthorName, "—"), *f.AuthorHandle)
|
||||||
|
if f.AuthorEmail != nil && *f.AuthorEmail != "" {
|
||||||
|
from += " — " + *f.AuthorEmail
|
||||||
|
}
|
||||||
|
case f.ContactEmail != nil && *f.ContactEmail != "":
|
||||||
|
from = "anonymous — " + *f.ContactEmail
|
||||||
|
default:
|
||||||
|
from = "anonymous"
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := feedbackEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"Category": f.Category,
|
||||||
|
"Message": f.Message,
|
||||||
|
"From": from,
|
||||||
|
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
|
||||||
|
"ID": f.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefOr(p *string, fallback string) string {
|
||||||
|
if p == nil || *p == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedbackResolvedEmailTpl is the author-facing thank-you sent when an
|
||||||
|
// admin marks their feedback resolved. Vaishnava-styled, short, and
|
||||||
|
// quotes back the exact feedback the devotee submitted so they know
|
||||||
|
// precisely what was addressed. Base font is 17px for comfortable
|
||||||
|
// reading on phones.
|
||||||
|
var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p>The feedback you shared with us has been addressed and the necessary
|
||||||
|
changes are now live:</p>
|
||||||
|
|
||||||
|
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
|
||||||
|
|
||||||
|
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
|
||||||
|
faithfully. Please continue using
|
||||||
|
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
|
||||||
|
with even greater enthusiasm.</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
|
|
||||||
|
// feedbackAcknowledgedEmailTpl is the author-facing "we've seen it"
|
||||||
|
// note sent when an admin presses "Acknowledge". Same Vaishnava styling
|
||||||
|
// and 17px base as the resolved email, quotes the submission back, and
|
||||||
|
// promises a follow-up email on completion.
|
||||||
|
var feedbackAcknowledgedEmailTpl = template.Must(template.New("feedbackAcknowledged").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p>Thank you for the feedback you shared with us:</p>
|
||||||
|
|
||||||
|
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
|
||||||
|
|
||||||
|
<p>We acknowledge your feedback and have noted it carefully. If it calls
|
||||||
|
for a new feature or a fix, our dev team has already begun working on it.
|
||||||
|
You'll receive another email from us once it has been implemented or
|
||||||
|
resolved.</p>
|
||||||
|
|
||||||
|
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
|
||||||
|
faithfully. Please continue using
|
||||||
|
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
|
||||||
|
with even greater enthusiasm.</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
|
|
||||||
|
func renderFeedbackAcknowledgedEmail(f *model.Feedback) (string, error) {
|
||||||
|
name := ""
|
||||||
|
if f.AuthorName != nil {
|
||||||
|
name = strings.TrimSpace(*f.AuthorName)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := feedbackAcknowledgedEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"Name": name,
|
||||||
|
"Message": strings.TrimSpace(f.Message),
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
|
||||||
|
// Prefer the account display name; fall back to no name (the
|
||||||
|
// template drops the comma cleanly when Name is empty).
|
||||||
|
name := ""
|
||||||
|
if f.AuthorName != nil {
|
||||||
|
name = strings.TrimSpace(*f.AuthorName)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := feedbackResolvedEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"Name": name,
|
||||||
|
"Message": strings.TrimSpace(f.Message),
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Package service — NotificationService is the single seam every
|
||||||
|
// user-facing event flows through ("the notification plugin"). Today it
|
||||||
|
// does two things per event:
|
||||||
|
//
|
||||||
|
// 1. Persists a row in `notifications` (so an in-app notification center
|
||||||
|
// and, later, websocket + push delivery can read the same history).
|
||||||
|
// 2. Sends an email to the recipient via the shared mailer.
|
||||||
|
//
|
||||||
|
// Everything here is fire-and-forget from the caller's perspective: a
|
||||||
|
// follow request must succeed even if the email bounces or the insert
|
||||||
|
// fails, so Notify* never returns an error — failures are logged. When
|
||||||
|
// websocket/push land they hook in right here alongside the email send.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification kinds — short machine strings stored in notifications.kind.
|
||||||
|
const (
|
||||||
|
NotifFollowRequest = "follow_request"
|
||||||
|
NotifFollowAccepted = "follow_accepted"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationService struct {
|
||||||
|
notifs *repository.NotificationRepo
|
||||||
|
users *repository.UserRepo
|
||||||
|
mail mailer.Mailer
|
||||||
|
logger *zap.Logger
|
||||||
|
frontendURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *NotificationService {
|
||||||
|
return &NotificationService{
|
||||||
|
notifs: notifs,
|
||||||
|
users: users,
|
||||||
|
mail: mail,
|
||||||
|
logger: logger,
|
||||||
|
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the caller's notifications, newest first.
|
||||||
|
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
return s.notifs.ListByUser(ctx, userID, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnreadCount returns the caller's unread notification count (for the bell
|
||||||
|
// badge).
|
||||||
|
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
|
||||||
|
return s.notifs.CountUnread(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllRead marks all of the caller's notifications read.
|
||||||
|
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
|
||||||
|
return s.notifs.MarkAllRead(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
|
||||||
|
// them. Persists the row and emails the recipient (if they have an email
|
||||||
|
// on file).
|
||||||
|
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
|
||||||
|
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
|
||||||
|
"%s requested to follow you — Prabhupāda Says")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
|
||||||
|
// follow request (i.e. the recipient can now see actor's activity).
|
||||||
|
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
|
||||||
|
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
|
||||||
|
"%s accepted your follow request — Prabhupāda Says")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch persists the notification and sends the matching email. All
|
||||||
|
// failures are logged, never propagated.
|
||||||
|
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
|
||||||
|
actor := actorID
|
||||||
|
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
|
||||||
|
s.logger.Warn("persist notification failed",
|
||||||
|
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||||||
|
// Persistence failed, but still try to email — the email is the
|
||||||
|
// only delivery channel that exists today.
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient, err := s.users.FindByID(ctx, recipientID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
||||||
|
// No email on file (OAuth-only account that never added one) —
|
||||||
|
// the persisted row is the notification; nothing to send.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actorUser, err := s.users.FindByID(ctx, actorID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := s.render(kind, recipient, actorUser)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
|
||||||
|
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
|
||||||
|
s.logger.Warn("notification email send failed",
|
||||||
|
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
|
||||||
|
data := map[string]any{
|
||||||
|
"RecipientName": recipient.Name,
|
||||||
|
"ActorName": actorDisplay(actor),
|
||||||
|
"ActorHandle": actor.Handle,
|
||||||
|
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
|
||||||
|
}
|
||||||
|
var tpl *template.Template
|
||||||
|
switch kind {
|
||||||
|
case NotifFollowRequest:
|
||||||
|
tpl = followRequestEmailTpl
|
||||||
|
case NotifFollowAccepted:
|
||||||
|
tpl = followAcceptedEmailTpl
|
||||||
|
default:
|
||||||
|
tpl = followRequestEmailTpl
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// actorDisplay prefers the display name, falling back to @handle.
|
||||||
|
func actorDisplay(u *model.User) string {
|
||||||
|
if n := strings.TrimSpace(u.Name); n != "" {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return "@" + u.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
|
||||||
|
// the actor and link to their profile (where the recipient can accept a
|
||||||
|
// pending request).
|
||||||
|
var followRequestEmailTpl = template.Must(template.New("followRequest").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
|
||||||
|
has requested to follow you on Prabhupāda Says.</p>
|
||||||
|
|
||||||
|
<p>You can review and accept or decline the request from their profile:</p>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
|
|
||||||
|
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
|
||||||
|
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1.5em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plan caps for the social features. Updates here propagate everywhere via
|
||||||
|
// EnforceBookmarkLimit / EnforceCollectionLimit.
|
||||||
|
const (
|
||||||
|
PlanFree = "free"
|
||||||
|
PlanPremium = "premium"
|
||||||
|
|
||||||
|
freeBookmarkLimit = 1
|
||||||
|
premiumBookmarkLimit = 5
|
||||||
|
|
||||||
|
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
|
||||||
|
// Premium has no collection cap.
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
|
||||||
|
// Handlers surface this as HTTP 402 (Payment Required) so clients can show
|
||||||
|
// an upsell. The message tells the user what cap was hit.
|
||||||
|
var ErrPlanLimit = errors.New("plan limit reached")
|
||||||
|
|
||||||
|
// PlanLimitError carries detail about which limit was hit so the frontend
|
||||||
|
// can render a helpful message instead of a generic "upgrade to premium".
|
||||||
|
type PlanLimitError struct {
|
||||||
|
Resource string // e.g. "bookmarks", "private collections"
|
||||||
|
Limit int
|
||||||
|
Plan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PlanLimitError) Error() string {
|
||||||
|
return fmt.Sprintf("%s limit reached (%d on %s plan)", e.Resource, e.Limit, e.Plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||||
|
|
||||||
|
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
||||||
|
// bookmark, or a PlanLimitError otherwise.
|
||||||
|
func EnforceBookmarkLimit(plan string, currentCount int) error {
|
||||||
|
limit := bookmarkLimitFor(plan)
|
||||||
|
if currentCount >= limit {
|
||||||
|
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnforceCollectionLimit returns nil if the user is allowed to create one
|
||||||
|
// more collection of the given visibility.
|
||||||
|
//
|
||||||
|
// `isPublic=true` → checks against public-collection cap
|
||||||
|
// `isPublic=false` → checks against private-collection cap
|
||||||
|
// Premium users have no cap → always nil.
|
||||||
|
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
|
||||||
|
if plan == PlanPremium {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
limit := freeCollectionPerVisibilityLimit
|
||||||
|
if currentCount >= limit {
|
||||||
|
resource := "private collections"
|
||||||
|
if isPublic {
|
||||||
|
resource = "public collections"
|
||||||
|
}
|
||||||
|
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bookmarkLimitFor(plan string) int {
|
||||||
|
if plan == PlanPremium {
|
||||||
|
return premiumBookmarkLimit
|
||||||
|
}
|
||||||
|
return freeBookmarkLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
|
||||||
|
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
|
||||||
|
|
||||||
|
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
|
||||||
|
func CollectionLimitFor(plan string) int {
|
||||||
|
if plan == PlanPremium {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return freeCollectionPerVisibilityLimit
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
// Package service — SocialGraphService is the follow / block / report
|
||||||
|
// brain. It sits over FollowRepo + BlockRepo + ReportRepo, resolves the
|
||||||
|
// target by handle, enforces the rules (no self-follow, no following
|
||||||
|
// across a block), keeps the request-based follow lifecycle honest, and
|
||||||
|
// routes every notable event through the NotificationService.
|
||||||
|
//
|
||||||
|
// Follows are request-based end to end: RequestFollow only ever creates a
|
||||||
|
// PENDING edge; the target turns it into a real follow via AcceptFollow.
|
||||||
|
// Every mutating method returns the freshly-recomputed RelationshipResult
|
||||||
|
// (the viewer's state toward the target + the target's follower/following
|
||||||
|
// counts) so the frontend can update its buttons and stat line from one
|
||||||
|
// response.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinels specific to the social graph (ErrForbidden / ErrNotFound are
|
||||||
|
// shared from social_service.go in this package).
|
||||||
|
var (
|
||||||
|
// ErrBlocked — the action can't proceed because one of the two users
|
||||||
|
// has blocked the other.
|
||||||
|
ErrBlocked = errors.New("blocked")
|
||||||
|
// ErrSelfRelation — you can't follow / block / report yourself.
|
||||||
|
ErrSelfRelation = errors.New("cannot perform this action on yourself")
|
||||||
|
)
|
||||||
|
|
||||||
|
// reportCategories is the allow-list the Report flow validates against —
|
||||||
|
// mirrored by the frontend's report dialog dropdown.
|
||||||
|
var reportCategories = map[string]bool{
|
||||||
|
"spam": true,
|
||||||
|
"harassment": true,
|
||||||
|
"impersonation": true,
|
||||||
|
"inappropriate": true,
|
||||||
|
"other": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// postReportCategories is the allow-list for reporting a post — mirrored
|
||||||
|
// by the frontend's post report dialog.
|
||||||
|
var postReportCategories = map[string]bool{
|
||||||
|
"spam": true,
|
||||||
|
"harassment": true,
|
||||||
|
"misinformation": true,
|
||||||
|
"inappropriate": true,
|
||||||
|
"other": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipResult is the payload every relationship mutation returns:
|
||||||
|
// the viewer's relationship to the target plus the target's live counts.
|
||||||
|
type RelationshipResult struct {
|
||||||
|
Relationship *model.RelationshipState `json:"relationship"`
|
||||||
|
FollowerCount int `json:"follower_count"`
|
||||||
|
FollowingCount int `json:"following_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocialGraphService struct {
|
||||||
|
follows *repository.FollowRepo
|
||||||
|
blocks *repository.BlockRepo
|
||||||
|
reports *repository.ReportRepo
|
||||||
|
posts *repository.PostRepo
|
||||||
|
users *repository.UserRepo
|
||||||
|
notif *NotificationService
|
||||||
|
mail mailer.Mailer
|
||||||
|
admins []string
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSocialGraphService(
|
||||||
|
follows *repository.FollowRepo,
|
||||||
|
blocks *repository.BlockRepo,
|
||||||
|
reports *repository.ReportRepo,
|
||||||
|
posts *repository.PostRepo,
|
||||||
|
users *repository.UserRepo,
|
||||||
|
notif *NotificationService,
|
||||||
|
mail mailer.Mailer,
|
||||||
|
admins []string,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *SocialGraphService {
|
||||||
|
return &SocialGraphService{
|
||||||
|
follows: follows, blocks: blocks, reports: reports, posts: posts,
|
||||||
|
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve looks up the target by handle and rejects self-targeting.
|
||||||
|
func (s *SocialGraphService) resolve(ctx context.Context, viewerID, handle string) (*model.User, error) {
|
||||||
|
t, err := s.users.FindByHandle(ctx, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // ErrUserNotFound → 404 via mapSocialErr
|
||||||
|
}
|
||||||
|
if t.ID == viewerID {
|
||||||
|
return nil, ErrSelfRelation
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestFollow inserts a pending follow edge viewer→target and notifies
|
||||||
|
// the target. Refuses if either party has blocked the other. Idempotent:
|
||||||
|
// re-requesting an existing edge doesn't re-notify.
|
||||||
|
func (s *SocialGraphService) RequestFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
viewerBlocked, targetBlocked, err := s.blocks.BlockState(ctx, viewerID, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if viewerBlocked || targetBlocked {
|
||||||
|
return nil, ErrBlocked
|
||||||
|
}
|
||||||
|
created, err := s.follows.Request(ctx, viewerID, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
s.notif.NotifyFollowRequest(ctx, t.ID, viewerID)
|
||||||
|
}
|
||||||
|
return s.result(ctx, viewerID, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfollow removes the viewer→target edge — covers both "stop following"
|
||||||
|
// (accepted) and "cancel my pending request". Idempotent.
|
||||||
|
func (s *SocialGraphService) Unfollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.follows.Delete(ctx, viewerID, t.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.result(ctx, viewerID, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptFollow accepts the target's pending request to follow the viewer
|
||||||
|
// (edge target→viewer). On the pending→accepted transition the target is
|
||||||
|
// notified that their request went through. `handle` is the requester.
|
||||||
|
func (s *SocialGraphService) AcceptFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
accepted, err := s.follows.Accept(ctx, t.ID, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if accepted {
|
||||||
|
s.notif.NotifyFollowAccepted(ctx, t.ID, viewerID)
|
||||||
|
}
|
||||||
|
return s.result(ctx, viewerID, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectFollow declines the target's pending request to follow the viewer
|
||||||
|
// by deleting the target→viewer edge. Idempotent; no notification (a
|
||||||
|
// silent decline is kinder than an explicit rejection email).
|
||||||
|
func (s *SocialGraphService) RejectFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.result(ctx, viewerID, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFollower drops the target from the viewer's followers by deleting
|
||||||
|
// the target→viewer edge. Mechanically the same as RejectFollow but
|
||||||
|
// distinct in intent (an accepted follower is being removed). Idempotent.
|
||||||
|
func (s *SocialGraphService) RemoveFollower(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.result(ctx, viewerID, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block records viewer→target and severs any follow edges both ways.
|
||||||
|
func (s *SocialGraphService) Block(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.blocks.Block(ctx, viewerID, t.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.result(ctx, viewerID, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock removes the viewer→target block.
|
||||||
|
func (s *SocialGraphService) Unblock(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.blocks.Unblock(ctx, viewerID, t.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.result(ctx, viewerID, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report files a report against the target and emails the admins. The
|
||||||
|
// category must be in the allow-list and the reason non-empty.
|
||||||
|
func (s *SocialGraphService) Report(ctx context.Context, viewerID, handle, category, reason string) error {
|
||||||
|
t, err := s.resolve(ctx, viewerID, handle)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
category = strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if !reportCategories[category] {
|
||||||
|
return errors.New("invalid report category")
|
||||||
|
}
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if reason == "" {
|
||||||
|
return errors.New("reason is required")
|
||||||
|
}
|
||||||
|
if len(reason) > 2000 {
|
||||||
|
return errors.New("reason too long (max 2000)")
|
||||||
|
}
|
||||||
|
if _, err := s.reports.Create(ctx, viewerID, t.ID, category, reason); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Email the admins — fire-and-forget; the row is already persisted.
|
||||||
|
reporter, rerr := s.users.FindByID(ctx, viewerID)
|
||||||
|
if rerr != nil {
|
||||||
|
s.logger.Warn("report saved but reporter lookup failed; emailing without reporter info",
|
||||||
|
zap.String("reporter", viewerID), zap.Error(rerr))
|
||||||
|
}
|
||||||
|
s.notifyAdminsOfReport(reporter, t, category, reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportPost files a report against a specific post and emails the admins.
|
||||||
|
// The category must be in the post allow-list; the reason is optional.
|
||||||
|
func (s *SocialGraphService) ReportPost(ctx context.Context, reporterID, postID, category, reason string) error {
|
||||||
|
category = strings.ToLower(strings.TrimSpace(category))
|
||||||
|
if !postReportCategories[category] {
|
||||||
|
return errors.New("invalid report category")
|
||||||
|
}
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if len(reason) > 2000 {
|
||||||
|
return errors.New("reason too long (max 2000)")
|
||||||
|
}
|
||||||
|
post, err := s.posts.FindByID(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return err // ErrPostNotFound → 404 via mapSocialErr
|
||||||
|
}
|
||||||
|
if _, err := s.reports.CreatePostReport(ctx, reporterID, post.ID, category, reason); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reporter, rerr := s.users.FindByID(ctx, reporterID)
|
||||||
|
if rerr != nil {
|
||||||
|
s.logger.Warn("post report saved but reporter lookup failed; emailing without reporter info",
|
||||||
|
zap.String("reporter", reporterID), zap.Error(rerr))
|
||||||
|
}
|
||||||
|
s.notifyAdminsOfPostReport(reporter, post, category, reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship computes the viewer's relationship toward targetID.
|
||||||
|
func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetID string) (*model.RelationshipState, error) {
|
||||||
|
out := &model.RelationshipState{}
|
||||||
|
outStatus, err := s.follows.Status(ctx, viewerID, targetID) // viewer → target
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inStatus, err := s.follows.Status(ctx, targetID, viewerID) // target → viewer
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Following = outStatus == "accepted"
|
||||||
|
out.FollowRequested = outStatus == "pending"
|
||||||
|
out.FollowsYou = inStatus == "accepted"
|
||||||
|
out.RequestsYou = inStatus == "pending"
|
||||||
|
|
||||||
|
viewerBlocked, blockedByTarget, err := s.blocks.BlockState(ctx, viewerID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Blocked = viewerBlocked
|
||||||
|
out.BlockedBy = blockedByTarget
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts returns the accepted-follower and accepted-following totals for
|
||||||
|
// a user.
|
||||||
|
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
|
||||||
|
followers, err = s.follows.CountFollowers(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
following, err = s.follows.CountFollowing(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return followers, following, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileExtras is the read used by the public-profile endpoint: the
|
||||||
|
// target's follower/following counts plus, when a non-owner viewer is
|
||||||
|
// signed in, their relationship to the target. rel is nil for anonymous
|
||||||
|
// viewers and for the owner viewing their own profile.
|
||||||
|
func (s *SocialGraphService) ProfileExtras(ctx context.Context, viewerID string, target *model.User) (followers, following int, rel *model.RelationshipState, err error) {
|
||||||
|
followers, following, err = s.Counts(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, nil, err
|
||||||
|
}
|
||||||
|
if viewerID == "" || viewerID == target.ID {
|
||||||
|
return followers, following, nil, nil
|
||||||
|
}
|
||||||
|
rel, err = s.Relationship(ctx, viewerID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, nil, err
|
||||||
|
}
|
||||||
|
return followers, following, rel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// result recomputes the viewer→target relationship and the target's
|
||||||
|
// counts after a mutation.
|
||||||
|
func (s *SocialGraphService) result(ctx context.Context, viewerID string, target *model.User) (*RelationshipResult, error) {
|
||||||
|
rel, err := s.Relationship(ctx, viewerID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
followers, following, err := s.Counts(ctx, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &RelationshipResult{
|
||||||
|
Relationship: rel,
|
||||||
|
FollowerCount: followers,
|
||||||
|
FollowingCount: following,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SocialGraphService) notifyAdminsOfReport(reporter, reported *model.User, category, reason string) {
|
||||||
|
if len(s.admins) == 0 {
|
||||||
|
s.logger.Info("user report received but no admin recipients configured",
|
||||||
|
zap.String("reported", reported.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from := "anonymous"
|
||||||
|
if reporter != nil {
|
||||||
|
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := userReportEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"ReporterFrom": from,
|
||||||
|
"ReportedName": reported.Name,
|
||||||
|
"ReportedHandle": reported.Handle,
|
||||||
|
"Category": category,
|
||||||
|
"Reason": reason,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render user-report email failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("[Report · %s] @%s", category, reported.Handle)
|
||||||
|
body := buf.String()
|
||||||
|
for _, to := range s.admins {
|
||||||
|
if err := s.mail.Send(to, subject, body); err != nil {
|
||||||
|
s.logger.Warn("user-report email send failed", zap.String("to", to), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var userReportEmailTpl = template.Must(template.New("userReport").Parse(`<p><strong>A user has been reported</strong></p>
|
||||||
|
<p><strong>Reported:</strong> {{.ReportedName}} (@{{.ReportedHandle}})<br>
|
||||||
|
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
|
||||||
|
<strong>Category:</strong> {{.Category}}</p>
|
||||||
|
<p><strong>Reason:</strong></p>
|
||||||
|
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>`))
|
||||||
|
|
||||||
|
func (s *SocialGraphService) notifyAdminsOfPostReport(reporter *model.User, post *model.Post, category, reason string) {
|
||||||
|
if len(s.admins) == 0 {
|
||||||
|
s.logger.Info("post report received but no admin recipients configured",
|
||||||
|
zap.String("post", post.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from := "anonymous"
|
||||||
|
if reporter != nil {
|
||||||
|
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
|
||||||
|
}
|
||||||
|
author := post.AuthorName
|
||||||
|
if post.AuthorHandle != "" {
|
||||||
|
author = fmt.Sprintf("%s (@%s)", post.AuthorName, post.AuthorHandle)
|
||||||
|
}
|
||||||
|
if author == "" {
|
||||||
|
author = "unknown"
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := postReportEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"PostID": post.ID,
|
||||||
|
"Author": author,
|
||||||
|
"ReporterFrom": from,
|
||||||
|
"Category": category,
|
||||||
|
"Reason": reason,
|
||||||
|
"Caption": post.Caption,
|
||||||
|
"SourceURL": post.SourceURL,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render post-report email failed", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject := fmt.Sprintf("[Post report · %s] %s", category, summarize(post.Caption, 50))
|
||||||
|
body := buf.String()
|
||||||
|
for _, to := range s.admins {
|
||||||
|
if err := s.mail.Send(to, subject, body); err != nil {
|
||||||
|
s.logger.Warn("post-report email send failed", zap.String("to", to), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var postReportEmailTpl = template.Must(template.New("postReport").Parse(`<p><strong>A post has been reported</strong></p>
|
||||||
|
<p><strong>Post ID:</strong> {{.PostID}}<br>
|
||||||
|
<strong>Author:</strong> {{.Author}}<br>
|
||||||
|
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
|
||||||
|
<strong>Category:</strong> {{.Category}}</p>
|
||||||
|
{{if .Reason}}<p><strong>Reason:</strong></p>
|
||||||
|
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>{{end}}
|
||||||
|
<p><strong>Caption:</strong></p>
|
||||||
|
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Caption}}</pre>
|
||||||
|
<p style="color:#999;font-size:12px"><a href="{{.SourceURL}}">{{.SourceURL}}</a></p>`))
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
// Package service — social bits of the BFF.
|
||||||
|
//
|
||||||
|
// `social_service.go` bundles three lightweight services (Post, Bookmark,
|
||||||
|
// Collection) because each is small (10–40 LOC of logic) and they share the
|
||||||
|
// same R2 + UserRepo dependencies. Keeping them together keeps `server.go`'s
|
||||||
|
// init-wiring tidy without sprawling into 4 separate files.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinels surfaced to handlers.
|
||||||
|
var (
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PostService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type PostService struct {
|
||||||
|
posts *repository.PostRepo
|
||||||
|
r2 *repository.R2
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService {
|
||||||
|
return &PostService{posts: posts, r2: r2}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create persists a new post. The caption is treated as opaque — we trust
|
||||||
|
// the frontend to send the exact text the user selected. Source URL must be
|
||||||
|
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
|
||||||
|
// was provided so links can't be hijacked.
|
||||||
|
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
||||||
|
caption = strings.TrimSpace(caption)
|
||||||
|
if caption == "" {
|
||||||
|
return nil, errors.New("caption is required")
|
||||||
|
}
|
||||||
|
if len(caption) > 4000 {
|
||||||
|
return nil, errors.New("caption too long (max 4000)")
|
||||||
|
}
|
||||||
|
sourceURL = stripHost(strings.TrimSpace(sourceURL))
|
||||||
|
if sourceURL == "" || !strings.HasPrefix(sourceURL, "/") {
|
||||||
|
return nil, errors.New("source_url must be an absolute path starting with /")
|
||||||
|
}
|
||||||
|
if len(imageURLs) > 5 {
|
||||||
|
return nil, errors.New("a post can hold up to 5 images")
|
||||||
|
}
|
||||||
|
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one post (public). `viewerID` is "" for anonymous callers;
|
||||||
|
// when set, the returned post carries the viewer's reshare state so the
|
||||||
|
// frontend can render the right action button without a second request.
|
||||||
|
func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post, error) {
|
||||||
|
p, err := s.posts.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.hydrateReshareStates(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reshare materialises a reshare as a brand-new posts row owned by the
|
||||||
|
// caller, with reshared_from_post_id pointing to the canonical original.
|
||||||
|
// Idempotent — calling it twice returns the same reshare row. Resharing
|
||||||
|
// one's own post (canonical or chained) is rejected at the repo layer.
|
||||||
|
//
|
||||||
|
// Returns the new post so the frontend can drop it into local state
|
||||||
|
// without a round-trip.
|
||||||
|
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) {
|
||||||
|
return s.posts.Reshare(ctx, postID, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreshare removes the caller's reshare of a canonical post by deleting
|
||||||
|
// their own reshare posts row. Idempotent — no-ops when nothing to delete.
|
||||||
|
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error {
|
||||||
|
return s.posts.Unreshare(ctx, postID, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrateReshareStates fills in ViewerHasReshared on each post via one
|
||||||
|
// batched lookup. Skips the query when the viewer is anonymous (the
|
||||||
|
// answer is uniformly false) or the slice is empty.
|
||||||
|
func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
|
||||||
|
if viewerID == "" || len(posts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(posts))
|
||||||
|
for _, p := range posts {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, p := range posts {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if states[p.ID] {
|
||||||
|
p.ViewerHasReshared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrateReshareStatesSlice is the value-slice variant of
|
||||||
|
// hydrateReshareStates. Repository list methods return []model.Post
|
||||||
|
// (not pointers) — we wrap each element so the helper can mutate in
|
||||||
|
// place via a stable pointer.
|
||||||
|
func (s *PostService) hydrateReshareStatesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ptrs := make([]*model.Post, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
ptrs[i] = &posts[i]
|
||||||
|
}
|
||||||
|
return s.hydrateReshareStates(ctx, viewerID, ptrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete checks ownership and removes the post.
|
||||||
|
func (s *PostService) Delete(ctx context.Context, postID, callerID string) error {
|
||||||
|
return s.posts.Delete(ctx, postID, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceImages swaps the image set on a post. Owner-only — every
|
||||||
|
// reshare is its own posts row, owned by the resharer, so this works
|
||||||
|
// uniformly for canonical posts and reshares.
|
||||||
|
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
|
||||||
|
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.posts.FindByID(ctx, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByHandle is the public feed view. `viewerID` is "" for anonymous
|
||||||
|
// callers; when set, each returned post carries the viewer's reshare
|
||||||
|
// state.
|
||||||
|
func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
posts, total, err := s.posts.ListByHandle(ctx, handle, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine is the owner's view of their own profile feed. Every post
|
||||||
|
// they own — authored or reshared — comes from posts.user_id, so the
|
||||||
|
// viewer is by construction the owner. ViewerHasReshared is true for
|
||||||
|
// reshare rows (the user IS the resharer) and false for canonical
|
||||||
|
// rows (you don't "reshare your own post").
|
||||||
|
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
posts, total, err := s.posts.ListByUser(ctx, userID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
if posts[i].ResharedFromPostID != nil {
|
||||||
|
posts[i].ViewerHasReshared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImagePresign returns a one-shot R2 PUT URL for a post image.
|
||||||
|
// The key is namespaced under post-images/<user_id>/<random>.<ext>. We
|
||||||
|
// don't tie it to a post_id at presign time because the post might not
|
||||||
|
// exist yet — the user uploads images first, then submits the post with
|
||||||
|
// the public URLs.
|
||||||
|
func (s *PostService) ImagePresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
return presignImage(ctx, s.r2, "post-images", userID, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BookmarkService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BookmarkService struct {
|
||||||
|
users *repository.UserRepo
|
||||||
|
bookmarks *repository.BookmarkRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBookmarkService(users *repository.UserRepo, bookmarks *repository.BookmarkRepo) *BookmarkService {
|
||||||
|
return &BookmarkService{users: users, bookmarks: bookmarks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create enforces the plan cap and inserts a new bookmark.
|
||||||
|
//
|
||||||
|
// If the same URL is already bookmarked (UNIQUE constraint), we return the
|
||||||
|
// existing row as a 200 idempotent response instead of an error — clicking
|
||||||
|
// "Bookmark" twice on the same passage should feel like a no-op.
|
||||||
|
func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.bookmarks.CountByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := s.bookmarks.Create(ctx, userID, stripHost(sourceURL), sourceTitle, excerpt, note)
|
||||||
|
if err == nil {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrBookmarkExists) {
|
||||||
|
// Idempotent: return whatever is already there.
|
||||||
|
list, lerr := s.bookmarks.ListByUser(ctx, userID)
|
||||||
|
if lerr != nil {
|
||||||
|
return nil, lerr
|
||||||
|
}
|
||||||
|
for _, existing := range list {
|
||||||
|
if existing.SourceURL == stripHost(sourceURL) {
|
||||||
|
return &existing, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the caller's bookmarks.
|
||||||
|
func (s *BookmarkService) List(ctx context.Context, userID string) ([]model.Bookmark, error) {
|
||||||
|
return s.bookmarks.ListByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a bookmark belonging to the caller.
|
||||||
|
func (s *BookmarkService) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
return s.bookmarks.Delete(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CollectionService
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type CollectionService struct {
|
||||||
|
users *repository.UserRepo
|
||||||
|
collections *repository.CollectionRepo
|
||||||
|
posts *repository.PostRepo
|
||||||
|
r2 *repository.R2
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollectionService(users *repository.UserRepo, collections *repository.CollectionRepo, posts *repository.PostRepo, r2 *repository.R2) *CollectionService {
|
||||||
|
return &CollectionService{users: users, collections: collections, posts: posts, r2: r2}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create enforces per-visibility plan caps and inserts.
|
||||||
|
func (s *CollectionService) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
|
||||||
|
if title = strings.TrimSpace(title); title == "" {
|
||||||
|
return nil, errors.New("title is required")
|
||||||
|
}
|
||||||
|
if len(title) > 120 {
|
||||||
|
return nil, errors.New("title too long (max 120)")
|
||||||
|
}
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, err := s.collections.CountByUserAndVisibility(ctx, userID, isPublic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update — only the owner may edit. Visibility changes that would exceed
|
||||||
|
// the plan cap are blocked.
|
||||||
|
func (s *CollectionService) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
|
||||||
|
c, err := s.collections.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return nil, ErrForbidden
|
||||||
|
}
|
||||||
|
// If the caller is flipping visibility, make sure they wouldn't blow the cap.
|
||||||
|
if isPublic != nil && *isPublic != c.IsPublic {
|
||||||
|
u, err := s.users.FindByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current, err := s.collections.CountByUserAndVisibility(ctx, userID, *isPublic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.collections.Update(ctx, id, userID, title, description, coverURL, isPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete — only the owner may delete.
|
||||||
|
func (s *CollectionService) Delete(ctx context.Context, id, userID string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.collections.Delete(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine returns the caller's collections (both public and private).
|
||||||
|
func (s *CollectionService) ListMine(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
|
||||||
|
return s.collections.ListByUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem inserts a post into a collection. Caller must own the collection.
|
||||||
|
// We don't require ownership of the *post* — collections can curate other
|
||||||
|
// users' public posts in a future iteration; for v1 the toolbar always
|
||||||
|
// creates a fresh post owned by the caller before calling this.
|
||||||
|
func (s *CollectionService) AddItem(ctx context.Context, collectionID, postID, callerID string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != callerID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
if _, err := s.posts.FindByID(ctx, postID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.collections.AddItem(ctx, collectionID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemOrder updates positions for the given post ids so the new order
|
||||||
|
// matches the input array. Owner-only.
|
||||||
|
func (s *CollectionService) SetItemOrder(ctx context.Context, collectionID, callerID string, postIDs []string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != callerID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.collections.SetItemOrder(ctx, collectionID, postIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionsContaining returns the ids of the caller's collections
|
||||||
|
// that already include the given post. Used by the save-to-collection
|
||||||
|
// drawer to disable rows the post is already in.
|
||||||
|
func (s *CollectionService) CollectionsContaining(ctx context.Context, postID, callerID string) ([]string, error) {
|
||||||
|
return s.collections.CollectionsContaining(ctx, postID, callerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem detaches a post from a collection (owner only).
|
||||||
|
func (s *CollectionService) RemoveItem(ctx context.Context, collectionID, postID, callerID string) error {
|
||||||
|
c, err := s.collections.FindByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != callerID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.collections.RemoveItem(ctx, collectionID, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one collection. Visibility is enforced by the caller —
|
||||||
|
// `viewerID` is the calling user id ("" if anonymous). If the collection is
|
||||||
|
// private and the viewer isn't the owner, returns ErrForbidden.
|
||||||
|
func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*model.CollectionRecord, []model.Post, error) {
|
||||||
|
c, err := s.collections.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if !c.IsPublic && c.UserID != viewerID {
|
||||||
|
return nil, nil, ErrForbidden
|
||||||
|
}
|
||||||
|
posts, err := s.collections.PostsIn(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// PostsIn returns posts without images (single-table boundary). Hydrate
|
||||||
|
// them in one batched query so callers — and the post cards rendered
|
||||||
|
// inside collection carousels — can show the post's first image as
|
||||||
|
// background instead of falling back to the empty quote panel.
|
||||||
|
if len(posts) > 0 {
|
||||||
|
ids := make([]string, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
ids[i] = posts[i].ID
|
||||||
|
}
|
||||||
|
imgs, err := s.posts.ImagesFor(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
posts[i].Images = imgs[posts[i].ID]
|
||||||
|
}
|
||||||
|
// Same batched approach for reshare states so the action footer
|
||||||
|
// on each card swaps Reshare↔Send correctly. Anonymous viewers
|
||||||
|
// (viewerID == "") get all-false at zero cost.
|
||||||
|
if viewerID != "" {
|
||||||
|
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
if states[posts[i].ID] {
|
||||||
|
posts[i].ViewerHasReshared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverPresign returns a one-shot R2 PUT URL for a collection cover image.
|
||||||
|
func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
return presignImage(ctx, s.r2, "collection-covers", userID, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// presignImage builds an R2 presign for a user-scoped media key under the
|
||||||
|
// given prefix. The random suffix invalidates browser caches when the image
|
||||||
|
// is replaced.
|
||||||
|
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
if r2 == nil {
|
||||||
|
return "", "", errors.New("media uploads are not configured")
|
||||||
|
}
|
||||||
|
ext, ok := imageExtensionFor(contentType)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||||
|
}
|
||||||
|
suffix := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(suffix); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
key := path.Join(prefix, userID, hex.EncodeToString(suffix)+ext)
|
||||||
|
return r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageExtensionFor(ct string) (string, bool) {
|
||||||
|
switch ct {
|
||||||
|
case "image/png":
|
||||||
|
return ".png", true
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg", true
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp", true
|
||||||
|
case "image/gif":
|
||||||
|
return ".gif", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHost reduces "https://prabhupadasays.org/books/bg/1/1?hl=x" to
|
||||||
|
// "/books/bg/1/1?hl=x" so we never store arbitrary external URLs.
|
||||||
|
// Anything that doesn't parse cleanly is returned unchanged (and rejected
|
||||||
|
// later by the prefix-check in the service).
|
||||||
|
func stripHost(u string) string {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u == "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(u, "/") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{"http://", "https://"} {
|
||||||
|
if strings.HasPrefix(u, prefix) {
|
||||||
|
rest := u[len(prefix):]
|
||||||
|
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||||
|
return rest[i:]
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserService handles profile-edit + avatar-upload flows.
|
||||||
|
type UserService struct {
|
||||||
|
users *repository.UserRepo
|
||||||
|
r2 *repository.R2
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService {
|
||||||
|
return &UserService{users: users, r2: r2}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile updates the user's name, avatar URL, and/or handle.
|
||||||
|
//
|
||||||
|
// `avatarURL` semantics:
|
||||||
|
//
|
||||||
|
// nil → don't touch
|
||||||
|
// "" → clear (remove avatar)
|
||||||
|
// "x" → set to "x"
|
||||||
|
//
|
||||||
|
// `handle` is normalized to the project's slug format before being passed
|
||||||
|
// to the DB so users can't sneak in spaces or punctuation. Empty after
|
||||||
|
// normalization → 400.
|
||||||
|
// UpdateProfileInput is the partial profile patch a handler hands the
|
||||||
|
// service. Every field is a pointer so nil means "leave unchanged". The
|
||||||
|
// service validates/normalizes each present field, then maps to a
|
||||||
|
// repository.ProfileUpdate.
|
||||||
|
type UpdateProfileInput struct {
|
||||||
|
Name *string
|
||||||
|
AvatarURL *string
|
||||||
|
Handle *string
|
||||||
|
Country *string
|
||||||
|
Sampradaya *string
|
||||||
|
Gender *string
|
||||||
|
Initiated *bool
|
||||||
|
InitiatedAt *string
|
||||||
|
DailyChantingRounds *int
|
||||||
|
PreferredChantingTime *string
|
||||||
|
PeakActiveTime *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// validGenders is the accepted set; "" clears the field.
|
||||||
|
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
|
||||||
|
|
||||||
|
// validSampradayas is the accepted lineage set. Currently just Gaudiya
|
||||||
|
// (the default); add codes here as more lineages are supported.
|
||||||
|
var validSampradayas = map[string]bool{"gaudiya": true}
|
||||||
|
|
||||||
|
func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*model.User, error) {
|
||||||
|
p := repository.ProfileUpdate{
|
||||||
|
AvatarURL: in.AvatarURL, // passthrough: nil=leave, ""=clear, x=set
|
||||||
|
Initiated: in.Initiated,
|
||||||
|
DailyChantingRounds: in.DailyChantingRounds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.Name != nil {
|
||||||
|
trimmed := strings.TrimSpace(*in.Name)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, errors.New("name cannot be empty")
|
||||||
|
}
|
||||||
|
if len(trimmed) > 120 {
|
||||||
|
return nil, errors.New("name too long (max 120)")
|
||||||
|
}
|
||||||
|
p.Name = &trimmed
|
||||||
|
}
|
||||||
|
if in.Handle != nil {
|
||||||
|
clean := normalizeHandle(*in.Handle)
|
||||||
|
if len(clean) < 3 {
|
||||||
|
return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)")
|
||||||
|
}
|
||||||
|
if len(clean) > 32 {
|
||||||
|
return nil, errors.New("handle too long (max 32)")
|
||||||
|
}
|
||||||
|
p.Handle = &clean
|
||||||
|
}
|
||||||
|
if in.Country != nil {
|
||||||
|
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
|
||||||
|
// frontend dropdown only ever sends valid codes).
|
||||||
|
cc := strings.ToUpper(strings.TrimSpace(*in.Country))
|
||||||
|
if cc != "" && len(cc) != 2 {
|
||||||
|
return nil, errors.New("country must be a 2-letter code")
|
||||||
|
}
|
||||||
|
p.Country = &cc
|
||||||
|
}
|
||||||
|
if in.Sampradaya != nil {
|
||||||
|
// "" clears (optional field); otherwise must be a known lineage.
|
||||||
|
s := strings.ToLower(strings.TrimSpace(*in.Sampradaya))
|
||||||
|
if s != "" && !validSampradayas[s] {
|
||||||
|
return nil, errors.New("unknown sampradaya")
|
||||||
|
}
|
||||||
|
p.Sampradaya = &s
|
||||||
|
}
|
||||||
|
if in.Gender != nil {
|
||||||
|
g := strings.ToLower(strings.TrimSpace(*in.Gender))
|
||||||
|
if g != "" && !validGenders[g] {
|
||||||
|
return nil, errors.New("gender must be male, female, or other")
|
||||||
|
}
|
||||||
|
p.Gender = &g
|
||||||
|
}
|
||||||
|
if in.InitiatedAt != nil {
|
||||||
|
d := strings.TrimSpace(*in.InitiatedAt)
|
||||||
|
if d != "" {
|
||||||
|
if _, err := time.Parse("2006-01-02", d); err != nil {
|
||||||
|
return nil, errors.New("initiated_at must be a date (YYYY-MM-DD)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.InitiatedAt = &d
|
||||||
|
}
|
||||||
|
if in.DailyChantingRounds != nil {
|
||||||
|
// 0 is the clear sentinel; real values are 1–1008 (DB enforces too).
|
||||||
|
n := *in.DailyChantingRounds
|
||||||
|
if n != 0 && (n < 1 || n > 1008) {
|
||||||
|
return nil, errors.New("daily_chanting_rounds must be between 1 and 1008")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.PreferredChantingTime != nil {
|
||||||
|
t, err := normalizeClock(*in.PreferredChantingTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("preferred_chanting_time must be HH:MM")
|
||||||
|
}
|
||||||
|
p.PreferredChantingTime = &t
|
||||||
|
}
|
||||||
|
if in.PeakActiveTime != nil {
|
||||||
|
t, err := normalizeClock(*in.PeakActiveTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("peak_active_time must be HH:MM")
|
||||||
|
}
|
||||||
|
p.PeakActiveTime = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.users.UpdateProfile(ctx, id, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeClock validates an "HH:MM" 24-hour clock time, returning "" for
|
||||||
|
// a blank input (clear). The system-managed notification fields use this.
|
||||||
|
func normalizeClock(raw string) (string, error) {
|
||||||
|
t := strings.TrimSpace(raw)
|
||||||
|
if t == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("15:04", t); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search returns users matching the query (handle or name substring).
|
||||||
|
// Used by the public user-search endpoint on the /search page.
|
||||||
|
func (s *UserService) Search(ctx context.Context, q string, limit int) ([]model.User, error) {
|
||||||
|
return s.users.Search(ctx, q, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadingPrefs returns the user's reading prefs blob (opaque JSON).
|
||||||
|
// Used by the frontend to hydrate ReadingSettings on sign-in so the
|
||||||
|
// theme/palette/font choices roam across devices.
|
||||||
|
func (s *UserService) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
|
||||||
|
return s.users.GetReadingPrefs(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateReadingPrefs overwrites the user's reading prefs blob. The
|
||||||
|
// handler re-marshals the decoded request before calling in here so we
|
||||||
|
// know `prefsJSON` is well-formed by the time it hits the DB.
|
||||||
|
func (s *UserService) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
|
||||||
|
return s.users.UpdateReadingPrefs(ctx, id, prefsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeHandle lowercases and strips anything that isn't [a-z0-9-]. Same
|
||||||
|
// rules as slugForHandle in auth_service, but exposed here because the
|
||||||
|
// profile-update path doesn't go through auth.
|
||||||
|
func normalizeHandle(raw string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-', r == '.', r == '_':
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
// Collapse runs of dashes.
|
||||||
|
for strings.Contains(out, "--") {
|
||||||
|
out = strings.ReplaceAll(out, "--", "-")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarPresign issues a one-shot presigned PUT URL the frontend uses to
|
||||||
|
// upload an avatar directly to R2.
|
||||||
|
//
|
||||||
|
// The key is namespaced under `avatars/<user_id>/<random>.<ext>` so:
|
||||||
|
// - we can list / clean up a user's avatars with one prefix scan
|
||||||
|
// - the random suffix forces cache invalidation when the avatar changes
|
||||||
|
// - a malicious user can't overwrite someone else's avatar (their JWT
|
||||||
|
// scopes the user_id portion of the key)
|
||||||
|
//
|
||||||
|
// Returns (uploadURL, publicURL, error). The frontend then PUTs to uploadURL
|
||||||
|
// and POSTs publicURL back as the new avatar.
|
||||||
|
func (s *UserService) AvatarPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
if s.r2 == nil {
|
||||||
|
return "", "", errors.New("avatar uploads are not configured")
|
||||||
|
}
|
||||||
|
ext, ok := extensionFor(contentType)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(suffix); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
key := path.Join("avatars", userID, hex.EncodeToString(suffix)+ext)
|
||||||
|
|
||||||
|
return s.r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extensionFor whitelists the image content types we accept. Restricting
|
||||||
|
// here is the cheap-but-effective defence against users uploading arbitrary
|
||||||
|
// binaries to our R2 bucket.
|
||||||
|
func extensionFor(ct string) (string, bool) {
|
||||||
|
switch ct {
|
||||||
|
case "image/png":
|
||||||
|
return ".png", true
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg", true
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp", true
|
||||||
|
case "image/gif":
|
||||||
|
return ".gif", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS users;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Required Postgres extensions:
|
||||||
|
-- pgcrypto → gen_random_uuid() for primary keys
|
||||||
|
-- citext → case-insensitive emails (so "Foo@x.com" == "foo@x.com")
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
|
||||||
|
-- Users table. Stores all auth + profile data.
|
||||||
|
--
|
||||||
|
-- Fields:
|
||||||
|
-- email unique, citext (case-insensitive)
|
||||||
|
-- password_hash bcrypt hash; never logged or returned in API responses
|
||||||
|
-- email_verified_at NULL until the user clicks the verification link
|
||||||
|
-- avatar_url absolute URL to a file in the R2 bucket (or NULL)
|
||||||
|
-- token_version incremented whenever issued JWTs should be invalidated
|
||||||
|
-- (password change, forced logout). JWTs carry this value
|
||||||
|
-- as a "tv" claim; mismatch ⇒ middleware rejects the token.
|
||||||
|
-- role "user" | "admin" | "moderator"
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email CITEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role VARCHAR(32) NOT NULL DEFAULT 'user',
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
token_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS users_created_at_idx ON users (created_at DESC);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS auth_tokens;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- One-shot tokens used by the forgot-password and email-verification flows.
|
||||||
|
--
|
||||||
|
-- We never store the raw token; only the SHA-256 hash. The plaintext lives
|
||||||
|
-- only in the email link. This means a DB leak can't be replayed against the
|
||||||
|
-- live flow.
|
||||||
|
--
|
||||||
|
-- `purpose` discriminates the two flows so we can keep one narrow table:
|
||||||
|
-- 'password_reset' → expires in ~1h
|
||||||
|
-- 'email_verification' → expires in ~24h
|
||||||
|
--
|
||||||
|
-- `used_at` is set the first time a token is consumed. The handler must check
|
||||||
|
-- both `used_at IS NULL` AND `expires_at > now()` before honouring a token.
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
purpose VARCHAR(32) NOT NULL,
|
||||||
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CHECK (purpose IN ('password_reset', 'email_verification'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS auth_tokens_user_purpose_idx
|
||||||
|
ON auth_tokens (user_id, purpose);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS auth_tokens_expires_at_idx
|
||||||
|
ON auth_tokens (expires_at);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS handle;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS plan;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Adds the social-handle + subscription-plan columns to `users`.
|
||||||
|
--
|
||||||
|
-- handle — case-insensitive (citext) URL slug, e.g. /u/satya-das.
|
||||||
|
-- Auto-generated from the email-local part on signup; user can
|
||||||
|
-- change it later via PATCH /users/me. Unique across the table.
|
||||||
|
-- plan — 'free' (default) or 'premium'. Controls limits enforced at
|
||||||
|
-- the service layer (bookmarks count, collection counts).
|
||||||
|
-- No checkout flow yet — admin flips it with SQL.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS handle CITEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS plan VARCHAR(16) NOT NULL DEFAULT 'free'
|
||||||
|
CHECK (plan IN ('free', 'premium'));
|
||||||
|
|
||||||
|
-- Backfill handles for any rows that already exist. The recipe mirrors the
|
||||||
|
-- generator the auth service uses: slugify the local-part of the email and
|
||||||
|
-- append a 6-char random suffix to make collisions vanishingly unlikely.
|
||||||
|
UPDATE users
|
||||||
|
SET handle = LOWER(REGEXP_REPLACE(SPLIT_PART(email::text, '@', 1), '[^a-z0-9]', '', 'g'))
|
||||||
|
|| '-' || substr(md5(id::text), 1, 6)
|
||||||
|
WHERE handle IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE users ALTER COLUMN handle SET NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'users_handle_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_handle_key UNIQUE (handle);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS post_images;
|
||||||
|
DROP TABLE IF EXISTS posts;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Posts are public quotes pulled from the knowledgebase. Each carries the
|
||||||
|
-- selected text verbatim as its caption (immutable after create) plus a link
|
||||||
|
-- back to the source URL the user was reading. Optional images are stored in
|
||||||
|
-- the `post_images` table, one row per image, ordered by `position`.
|
||||||
|
--
|
||||||
|
-- Posts are always public — visibility is a per-user concern (the user's
|
||||||
|
-- /u/<handle> feed), not a per-post one. Private saves should use bookmarks.
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
caption TEXT NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_title VARCHAR(200),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS posts_user_created_idx
|
||||||
|
ON posts (user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_images (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS post_images_post_idx
|
||||||
|
ON post_images (post_id, position);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS bookmarks;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Bookmarks are always private — they're the user's "save my place" markers.
|
||||||
|
-- We don't store the full passage text, only the source URL + a short excerpt
|
||||||
|
-- so the bookmarks index can render meaningful previews without a separate
|
||||||
|
-- content fetch.
|
||||||
|
--
|
||||||
|
-- UNIQUE (user_id, source_url) means re-bookmarking the same passage is a
|
||||||
|
-- no-op rather than producing a duplicate; the service maps the unique
|
||||||
|
-- violation to a 200 idempotent response.
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
source_title VARCHAR(200),
|
||||||
|
excerpt TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (user_id, source_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS bookmarks_user_created_idx
|
||||||
|
ON bookmarks (user_id, created_at DESC);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
|
||||||
|
DROP FUNCTION IF EXISTS sync_collection_item_count();
|
||||||
|
DROP TABLE IF EXISTS collection_items;
|
||||||
|
DROP TABLE IF EXISTS collections;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Collections group posts/quotes. They carry their own visibility flag —
|
||||||
|
-- a private collection holds posts that are themselves public on the user's
|
||||||
|
-- feed, but the collection page is only visible to the owner.
|
||||||
|
--
|
||||||
|
-- `item_count` is denormalized so listing pages don't need a join+count.
|
||||||
|
-- It's kept in sync by collection_items triggers below.
|
||||||
|
CREATE TABLE IF NOT EXISTS collections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(120) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
cover_url TEXT,
|
||||||
|
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
item_count INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS collections_user_created_idx
|
||||||
|
ON collections (user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- Public-collection lookups by owner+visibility (for /u/<handle> page).
|
||||||
|
CREATE INDEX IF NOT EXISTS collections_user_public_idx
|
||||||
|
ON collections (user_id, is_public) WHERE is_public = TRUE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collection_items (
|
||||||
|
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (collection_id, post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS collection_items_position_idx
|
||||||
|
ON collection_items (collection_id, position);
|
||||||
|
|
||||||
|
-- Keep item_count in sync.
|
||||||
|
CREATE OR REPLACE FUNCTION sync_collection_item_count() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
UPDATE collections SET item_count = item_count + 1, updated_at = now()
|
||||||
|
WHERE id = NEW.collection_id;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
UPDATE collections SET item_count = GREATEST(item_count - 1, 0), updated_at = now()
|
||||||
|
WHERE id = OLD.collection_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS collection_items_count_trg ON collection_items;
|
||||||
|
CREATE TRIGGER collection_items_count_trg
|
||||||
|
AFTER INSERT OR DELETE ON collection_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION sync_collection_item_count();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS reading_prefs;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Adds per-user reading preferences (theme palette, font family, font
|
||||||
|
-- size, justification toggle) so the prefs roam across devices once the
|
||||||
|
-- user signs in.
|
||||||
|
--
|
||||||
|
-- Stored as JSONB rather than a column-per-field — the shape is owned
|
||||||
|
-- by the frontend (components/reading-settings.tsx), changes
|
||||||
|
-- occasionally (new palettes added every now and then), and we never
|
||||||
|
-- query individual keys server-side. JSONB keeps the schema stable as
|
||||||
|
-- the shape evolves.
|
||||||
|
--
|
||||||
|
-- Default `'{}'::jsonb` so existing rows are not nulled and the
|
||||||
|
-- frontend can treat the server response as "merge into local defaults"
|
||||||
|
-- without an additional null check.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS reading_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_post_reshares_user_created;
|
||||||
|
DROP TABLE IF EXISTS post_reshares;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Tracks which posts a user has reshared. Pure join table — no
|
||||||
|
-- denormalised counters, no duplicate post rows. The act of resharing
|
||||||
|
-- is a relationship between (post, user); the post itself stays in
|
||||||
|
-- one canonical row owned by its original author.
|
||||||
|
--
|
||||||
|
-- This powers the post-card swap: posts a user has authored OR
|
||||||
|
-- reshared show the "Send" (external share) button instead of
|
||||||
|
-- "Reshare", because there's nothing left to bring onto the account.
|
||||||
|
--
|
||||||
|
-- Composite PK on (post_id, user_id) makes the relationship unique
|
||||||
|
-- and gives us a covered lookup for the "did this viewer reshare this
|
||||||
|
-- post?" check used in batched feed hydration.
|
||||||
|
--
|
||||||
|
-- Index on (user_id, created_at DESC) keeps "what has this user
|
||||||
|
-- reshared, newest first?" cheap for the day we surface that on the
|
||||||
|
-- profile feed.
|
||||||
|
CREATE TABLE IF NOT EXISTS post_reshares (
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (post_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_reshares_user_created
|
||||||
|
ON post_reshares (user_id, created_at DESC);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_feedback_created;
|
||||||
|
DROP TABLE IF EXISTS feedback;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Feedback collects user-submitted bug reports, feature requests, and
|
||||||
|
-- general comments captured by the frontend's /feedback page. Two
|
||||||
|
-- consumers:
|
||||||
|
-- 1. The team — every insert fans out an HTML notification to every
|
||||||
|
-- address in FEEDBACK_ADMIN_EMAILS via the existing mailer.
|
||||||
|
-- 2. The /stats ops page — aggregate counts + the most-recent N rows
|
||||||
|
-- render together so we can scan recent feedback without having to
|
||||||
|
-- open the DB.
|
||||||
|
--
|
||||||
|
-- user_id is nullable (and ON DELETE SET NULL on cascade) because:
|
||||||
|
-- • Anonymous visitors submit feedback too — they fill in the optional
|
||||||
|
-- contact_email field instead of leaving an account behind.
|
||||||
|
-- • When a user later deletes their account we keep the row but
|
||||||
|
-- forget who sent it. The content stays useful as product signal
|
||||||
|
-- even after the author is gone.
|
||||||
|
--
|
||||||
|
-- contact_email is the soft reply-to surfaced only for the anonymous
|
||||||
|
-- case; the logged-in case derives reply-to from the joined user row's
|
||||||
|
-- email at admin-notification time.
|
||||||
|
--
|
||||||
|
-- Index on created_at DESC keeps the /stats "recent feedback" list
|
||||||
|
-- cheap as the table grows.
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
contact_email TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_created
|
||||||
|
ON feedback (created_at DESC);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS oauth_accounts;
|
||||||
|
-- Note: re-imposing NOT NULL on users.password_hash would require backfilling
|
||||||
|
-- any OAuth-only users with a dummy hash; left as a manual step.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- OAuth account linkage.
|
||||||
|
--
|
||||||
|
-- One row per (provider, provider_user_id). A single user can have several
|
||||||
|
-- providers linked (e.g. signed up with email + later linked Google). The
|
||||||
|
-- provider's `sub` (Google) / `id` (Facebook) is the stable identity — email
|
||||||
|
-- is captured for display but is NOT the join key, since providers can let
|
||||||
|
-- users change the email on the account.
|
||||||
|
--
|
||||||
|
-- When a callback fires:
|
||||||
|
-- 1. Look up (provider, provider_user_id) — found → log that user in.
|
||||||
|
-- 2. Otherwise look up users.email — found → link new oauth_accounts row
|
||||||
|
-- to the existing user and log them in.
|
||||||
|
-- 3. Otherwise create a new user (password_hash NULL) + an oauth_accounts
|
||||||
|
-- row, log them in.
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider VARCHAR(32) NOT NULL, -- "google" | "facebook"
|
||||||
|
provider_user_id TEXT NOT NULL, -- sub (Google) / id (Facebook)
|
||||||
|
email CITEXT,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (provider, provider_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS oauth_accounts_user_id_idx ON oauth_accounts (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS oauth_accounts_provider_idx ON oauth_accounts (provider);
|
||||||
|
|
||||||
|
-- Password hash must be optional now: OAuth-only users have no password.
|
||||||
|
-- Existing email/password users keep their hash; new OAuth users get NULL
|
||||||
|
-- and can set a password later via the forgot-password flow if they want
|
||||||
|
-- a fallback login method.
|
||||||
|
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Reverting this requires backfilling NULL emails with synthetic values
|
||||||
|
-- first; left as a manual step (see operational runbook).
|
||||||
|
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Some auth providers (Facebook in particular) let users sign up without
|
||||||
|
-- linking a verified email — phone-only accounts and accounts whose owner
|
||||||
|
-- declined the `email` permission both fall under this. To accept those
|
||||||
|
-- users we need to allow NULL on users.email.
|
||||||
|
--
|
||||||
|
-- The UNIQUE constraint stays put: Postgres treats NULLs as distinct under
|
||||||
|
-- a regular UNIQUE index (multiple rows with NULL email are allowed) so
|
||||||
|
-- nothing breaks for OAuth-only users with no address on file. Real email
|
||||||
|
-- addresses are still de-duplicated, exactly as before.
|
||||||
|
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS post_reshare_images;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Per-reshare background image overrides.
|
||||||
|
--
|
||||||
|
-- Previously every viewer of a reshare saw the canonical post_images row —
|
||||||
|
-- so when either the original author or any resharer "Updated background"
|
||||||
|
-- the change leaked across all timelines that referenced the post. This
|
||||||
|
-- table separates "the post's background" from "this user's reshare of
|
||||||
|
-- the post's background": authoring stays in post_images, resharing
|
||||||
|
-- stores its own snapshot here.
|
||||||
|
--
|
||||||
|
-- At reshare time we copy the post's current post_images into this table
|
||||||
|
-- so the resharer's feed renders identically to the author's. After that
|
||||||
|
-- moment the two are independent — the resharer's background lives only
|
||||||
|
-- here, the original author's lives only in post_images, and changes to
|
||||||
|
-- one never touch the other.
|
||||||
|
--
|
||||||
|
-- Cascade: the FK targets the (post_id, user_id) primary key on
|
||||||
|
-- post_reshares. Every path that removes a reshare relationship (the
|
||||||
|
-- author deletes the post, the resharer un-reshares, either user is
|
||||||
|
-- deleted) drops the post_reshares row, which cascades the overrides
|
||||||
|
-- away. The user's stated rule — "should not be in sync except deletion"
|
||||||
|
-- — is preserved end-to-end without any extra application code.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_reshare_images (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (post_id, user_id)
|
||||||
|
REFERENCES post_reshares (post_id, user_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_reshare_images_lookup
|
||||||
|
ON post_reshare_images (post_id, user_id, position);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Reverting this requires materialising the new reshared posts back into
|
||||||
|
-- the post_reshares + post_reshare_images join model and then dropping
|
||||||
|
-- the column. Documented but not automated — run manually if a rollback
|
||||||
|
-- is ever actually needed.
|
||||||
|
--
|
||||||
|
-- Step 1: recreate the old tables.
|
||||||
|
-- Step 2: insert one row per reshare back into post_reshares.
|
||||||
|
-- Step 3: copy each reshare's post_images back into post_reshare_images.
|
||||||
|
-- Step 4: delete the materialised reshare rows from `posts`.
|
||||||
|
-- Step 5: drop the column.
|
||||||
|
|
||||||
|
ALTER TABLE posts DROP COLUMN IF EXISTS reshared_from_post_id;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- Reshares become real `posts` rows.
|
||||||
|
--
|
||||||
|
-- Previously a reshare was a join-table relationship between (post, user)
|
||||||
|
-- that pointed at the original author's canonical row. Likes, comments,
|
||||||
|
-- and (most recently) background overrides all had to be plumbed through
|
||||||
|
-- a parallel "per-resharer" path to avoid leaking across timelines. The
|
||||||
|
-- next set of social features (likes, comments) would multiply that
|
||||||
|
-- complexity again.
|
||||||
|
--
|
||||||
|
-- This migration collapses that model: every reshare is its own row in
|
||||||
|
-- `posts`, owned by the resharer, with a `reshared_from_post_id` pointer
|
||||||
|
-- back to the canonical original. Likes and comments will naturally hang
|
||||||
|
-- off post.id and diverge for free; backgrounds are already per-row in
|
||||||
|
-- post_images so the per-reshare override table goes away.
|
||||||
|
--
|
||||||
|
-- Deletion still cascades: the FK uses ON DELETE CASCADE on
|
||||||
|
-- reshared_from_post_id, so an author deleting their post nukes every
|
||||||
|
-- reshare of it (including their post_images via the existing FK).
|
||||||
|
|
||||||
|
-- 1. New self-referential column. Nullable: NULL on canonical posts,
|
||||||
|
-- set on reshares.
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS reshared_from_post_id UUID
|
||||||
|
REFERENCES posts(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_reshared_from
|
||||||
|
ON posts (reshared_from_post_id)
|
||||||
|
WHERE reshared_from_post_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Composite index used by ReshareStatesFor — given a viewer + a batch of
|
||||||
|
-- canonical post ids, "which of these has the viewer reshared?".
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_user_reshared_from
|
||||||
|
ON posts (user_id, reshared_from_post_id)
|
||||||
|
WHERE reshared_from_post_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Backfill: each row in post_reshares becomes a real `posts` row,
|
||||||
|
-- copying the canonical post's textual fields and the timestamp the
|
||||||
|
-- reshare originally happened. The new row is owned by the resharer.
|
||||||
|
--
|
||||||
|
-- Skipped if post_reshares doesn't exist (clean install) — the
|
||||||
|
-- DO block uses to_regclass so a fresh database doesn't error.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.post_reshares') IS NOT NULL THEN
|
||||||
|
INSERT INTO posts (
|
||||||
|
id, user_id, caption, source_url, source_title,
|
||||||
|
created_at, updated_at, reshared_from_post_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
r.user_id,
|
||||||
|
p.caption,
|
||||||
|
p.source_url,
|
||||||
|
p.source_title,
|
||||||
|
r.created_at,
|
||||||
|
r.created_at,
|
||||||
|
p.id
|
||||||
|
FROM post_reshares r
|
||||||
|
JOIN posts p ON p.id = r.post_id;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- 3. Carry the per-resharer image overrides over. Each override row maps
|
||||||
|
-- to the newly-inserted post via (reshared_from_post_id, user_id).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.post_reshare_images') IS NOT NULL THEN
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
SELECT np.id, ri.url, ri.position
|
||||||
|
FROM post_reshare_images ri
|
||||||
|
JOIN posts np
|
||||||
|
ON np.reshared_from_post_id = ri.post_id
|
||||||
|
AND np.user_id = ri.user_id;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- 4. Old tables are no longer referenced anywhere — drop them so the
|
||||||
|
-- schema mirrors the new model.
|
||||||
|
DROP TABLE IF EXISTS post_reshare_images;
|
||||||
|
DROP TABLE IF EXISTS post_reshares;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- This is a data-only backfill — there's no clean rollback because we
|
||||||
|
-- can't distinguish post_images rows added by this migration from rows
|
||||||
|
-- the resharer added afterwards. Intentional no-op; documented for the
|
||||||
|
-- runbook only.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Backfill the "reshares inherit the canonical's images" rule for any
|
||||||
|
-- reshare row that landed in `posts` without an image set.
|
||||||
|
--
|
||||||
|
-- Two paths created such rows:
|
||||||
|
--
|
||||||
|
-- 1. Reshares made BEFORE migration 0014 had no per-resharer image
|
||||||
|
-- table to snapshot into, so when 0015 collapsed the old
|
||||||
|
-- post_reshares model into real posts rows there was nothing for
|
||||||
|
-- it to copy — those reshares now show with a blank background
|
||||||
|
-- where the original author has a cover image.
|
||||||
|
--
|
||||||
|
-- 2. Any future code path that creates a reshare row but forgets to
|
||||||
|
-- copy the images. Defensive — current `Reshare` in
|
||||||
|
-- internal/repository/post_repo.go does copy, but this migration
|
||||||
|
-- makes the database self-healing if we ever regress.
|
||||||
|
--
|
||||||
|
-- The copy is scoped to reshares with ZERO images of their own so we
|
||||||
|
-- never overwrite a resharer's explicit customization (including an
|
||||||
|
-- explicit "no background" — though that path didn't exist at the time
|
||||||
|
-- the affected rows were created, the guard keeps us safe for any
|
||||||
|
-- future re-runs of equivalent backfills).
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
SELECT r.id, i.url, i.position
|
||||||
|
FROM posts r
|
||||||
|
JOIN post_images i ON i.post_id = r.reshared_from_post_id
|
||||||
|
WHERE r.reshared_from_post_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM post_images WHERE post_id = r.id
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_feedback_unresolved;
|
||||||
|
ALTER TABLE feedback DROP COLUMN IF EXISTS resolved_at;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Track when a feedback row was resolved.
|
||||||
|
--
|
||||||
|
-- NULL = still open. Non-NULL = closed at that moment. The action is
|
||||||
|
-- one-way: the resolve endpoint enforces `WHERE resolved_at IS NULL` so
|
||||||
|
-- a row already marked stays marked, and a subsequent re-resolve is a
|
||||||
|
-- no-op (no new email goes out).
|
||||||
|
--
|
||||||
|
-- We don't store "who" resolved it because the /stats page surfaces
|
||||||
|
-- this to the team and any audit-quality "who closed what" trail would
|
||||||
|
-- want its own table — out of scope for now.
|
||||||
|
ALTER TABLE feedback
|
||||||
|
ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Partial index makes "give me the still-open feedback" cheap as the
|
||||||
|
-- table grows. The /stats page lists newest-first regardless of state,
|
||||||
|
-- so the existing created_at index still serves its read path.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_unresolved
|
||||||
|
ON feedback (created_at DESC)
|
||||||
|
WHERE resolved_at IS NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE feedback DROP COLUMN IF EXISTS acknowledged_at;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Track when a feedback row was acknowledged.
|
||||||
|
--
|
||||||
|
-- Acknowledgement is the intermediate state between "open" and
|
||||||
|
-- "resolved": the team has seen the feedback and (if it needs work) the
|
||||||
|
-- devs have started on it. The author gets an email saying so, with a
|
||||||
|
-- promise of a second email once it's actually fixed/implemented.
|
||||||
|
--
|
||||||
|
-- NULL = not yet acknowledged. Non-NULL = acknowledged at that moment.
|
||||||
|
-- Like resolved_at, the action is one-way and idempotent: the endpoint
|
||||||
|
-- enforces `WHERE acknowledged_at IS NULL` so the acknowledgement email
|
||||||
|
-- fires at most once.
|
||||||
|
--
|
||||||
|
-- The two columns are independent stamps. An admin may acknowledge then
|
||||||
|
-- later resolve (two emails), or resolve directly without acknowledging
|
||||||
|
-- (one email). Resolving does not require a prior acknowledgement.
|
||||||
|
ALTER TABLE feedback
|
||||||
|
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TABLE IF EXISTS notifications;
|
||||||
|
DROP TABLE IF EXISTS user_reports;
|
||||||
|
DROP TABLE IF EXISTS user_blocks;
|
||||||
|
DROP TABLE IF EXISTS follows;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- Social graph: follows, blocks, reports, plus a generic notifications
|
||||||
|
-- table that backs the "notification plugin" (email today; websocket +
|
||||||
|
-- push will read the same rows later).
|
||||||
|
--
|
||||||
|
-- Follows are REQUEST-BASED. Clicking "Follow" inserts a row with
|
||||||
|
-- status='pending'; the target later accepts (status→'accepted',
|
||||||
|
-- responded_at stamped) or rejects (row deleted). A user is "following"
|
||||||
|
-- another only once the edge is 'accepted'. The UNIQUE(follower,
|
||||||
|
-- following) pair makes a repeated request idempotent, and the CHECK
|
||||||
|
-- stops anyone following themselves.
|
||||||
|
CREATE TABLE IF NOT EXISTS follows (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending | accepted
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
responded_at TIMESTAMPTZ, -- set when accepted
|
||||||
|
UNIQUE (follower_id, following_id),
|
||||||
|
CHECK (follower_id <> following_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Who follows me / who has requested me" — the incoming side. Filtered
|
||||||
|
-- by status so accepted-follower counts and pending-request lists are
|
||||||
|
-- both index-served.
|
||||||
|
CREATE INDEX IF NOT EXISTS follows_following_status_idx
|
||||||
|
ON follows (following_id, status);
|
||||||
|
|
||||||
|
-- "Who I follow / who I've requested" — the outgoing side.
|
||||||
|
CREATE INDEX IF NOT EXISTS follows_follower_status_idx
|
||||||
|
ON follows (follower_id, status);
|
||||||
|
|
||||||
|
-- Blocks. blocker_id has blocked blocked_id. One row per direction; the
|
||||||
|
-- service treats a block in EITHER direction as "these two can't follow
|
||||||
|
-- each other" and tears down any existing follow edges both ways.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (blocker_id, blocked_id),
|
||||||
|
CHECK (blocker_id <> blocked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reverse lookup: "has anyone blocked me?" — used to compute blocked_by
|
||||||
|
-- when rendering a profile.
|
||||||
|
CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx
|
||||||
|
ON user_blocks (blocked_id);
|
||||||
|
|
||||||
|
-- Reports. A reporter flags another user with a category + free-text
|
||||||
|
-- reason. resolved_at is one-way (admins clear it later); the partial
|
||||||
|
-- index keeps the open-reports queue cheap to list.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reported_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category VARCHAR(32) NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
CHECK (reporter_id <> reported_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_reports_reported_idx
|
||||||
|
ON user_reports (reported_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_reports_unresolved_idx
|
||||||
|
ON user_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
-- Notifications back the in-app/email/push fan-out. Today the
|
||||||
|
-- NotificationService persists a row here AND sends an email; websocket
|
||||||
|
-- + push delivery will later read unread rows for a user. actor_id is
|
||||||
|
-- the user who triggered the event (nullable for system notifications);
|
||||||
|
-- kind is a short machine string ('follow_request', 'follow_accepted').
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- recipient
|
||||||
|
actor_id UUID REFERENCES users(id) ON DELETE CASCADE, -- who caused it
|
||||||
|
kind VARCHAR(32) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
read_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "My notifications, newest first" + an unread filter for badge counts.
|
||||||
|
CREATE INDEX IF NOT EXISTS notifications_user_created_idx
|
||||||
|
ON notifications (user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
|
||||||
|
ON notifications (user_id) WHERE read_at IS NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS country;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Account country (ISO-3166-1 alpha-2), shown in the profile "About this
|
||||||
|
-- account" panel. Captured best-effort at signup (Cloudflare's
|
||||||
|
-- CF-IPCountry header when present, else a GeoIP lookup on the client IP)
|
||||||
|
-- and editable by the user from the profile edit form. NULL until known.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS country CHAR(2);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS gender,
|
||||||
|
DROP COLUMN IF EXISTS initiated,
|
||||||
|
DROP COLUMN IF EXISTS initiated_at,
|
||||||
|
DROP COLUMN IF EXISTS daily_chanting_rounds,
|
||||||
|
DROP COLUMN IF EXISTS preferred_chanting_time,
|
||||||
|
DROP COLUMN IF EXISTS peak_active_time;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Devotional profile details, plus two system-managed fields reserved for
|
||||||
|
-- the (not-yet-built) automated-notification scheduler.
|
||||||
|
--
|
||||||
|
-- gender / initiated / initiated_at / daily_chanting_rounds are
|
||||||
|
-- user-editable from the profile form. preferred_chanting_time and
|
||||||
|
-- peak_active_time are written silently by the backend later (no UI yet) —
|
||||||
|
-- both store a local clock time as "HH:MM" text so the scheduler can pick
|
||||||
|
-- a send window without timezone math at write time.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS gender VARCHAR(16),
|
||||||
|
ADD COLUMN IF NOT EXISTS initiated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS initiated_at DATE,
|
||||||
|
ADD COLUMN IF NOT EXISTS daily_chanting_rounds INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS preferred_chanting_time VARCHAR(5),
|
||||||
|
ADD COLUMN IF NOT EXISTS peak_active_time VARCHAR(5);
|
||||||
|
|
||||||
|
-- Guard the chanting-round count to the meaningful range (1 round up to a
|
||||||
|
-- full lakh of names = 1008). NULL stays allowed (not specified).
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_daily_chanting_rounds_range
|
||||||
|
CHECK (daily_chanting_rounds IS NULL
|
||||||
|
OR (daily_chanting_rounds BETWEEN 1 AND 1008));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS sampradaya;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Devotee's sampradāya (spiritual lineage). Every account defaults to the
|
||||||
|
-- Gaudiya sampradāya; the column is NOT NULL so the value is always
|
||||||
|
-- present. Stored as a lowercase code; the frontend maps it to a display
|
||||||
|
-- label. Existing rows are backfilled to 'gaudiya' by the DEFAULT.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS sampradaya VARCHAR(32) NOT NULL DEFAULT 'gaudiya';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Re-imposing NOT NULL requires backfilling any NULLs first.
|
||||||
|
UPDATE users SET sampradaya = 'gaudiya' WHERE sampradaya IS NULL;
|
||||||
|
ALTER TABLE users
|
||||||
|
ALTER COLUMN sampradaya SET NOT NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Sampradāya is optional. Keep the 'gaudiya' DEFAULT so new accounts still
|
||||||
|
-- start in the Gaudiya lineage, but allow NULL so a devotee can clear it.
|
||||||
|
ALTER TABLE users
|
||||||
|
ALTER COLUMN sampradaya DROP NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS post_reports;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Post reports — a viewer flags a specific post (not its author) with a
|
||||||
|
-- category + optional free-text reason. Mirrors user_reports but keyed to
|
||||||
|
-- a post; resolved_at is one-way (admins clear it later). The partial
|
||||||
|
-- index keeps the open-reports queue cheap to list.
|
||||||
|
CREATE TABLE IF NOT EXISTS post_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
category VARCHAR(32) NOT NULL,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
resolved_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS post_reports_post_idx
|
||||||
|
ON post_reports (post_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS post_reports_unresolved_idx
|
||||||
|
ON post_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CookieName is the single httpOnly cookie that carries the JWT.
|
||||||
|
const CookieName = "spsays_auth"
|
||||||
|
|
||||||
|
// CookieOptions captures the per-environment knobs for the auth cookie.
|
||||||
|
type CookieOptions struct {
|
||||||
|
Domain string // empty in dev; ".prabhupadasays.org" in prod for subdomain sharing
|
||||||
|
Secure bool // true in production (HTTPS-only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set writes the JWT into a Secure, httpOnly, SameSite=Lax cookie.
|
||||||
|
// SameSite=Lax + same-registrable-domain (prabhupadasays.org / api.prabhupadasays.org)
|
||||||
|
// is enough for cross-subdomain auth without resorting to SameSite=None.
|
||||||
|
func Set(w http.ResponseWriter, token string, expires time.Time, opts CookieOptions) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Domain: opts.Domain,
|
||||||
|
Expires: expires,
|
||||||
|
MaxAge: int(time.Until(expires).Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: opts.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear overwrites the auth cookie with an immediately-expired one.
|
||||||
|
func Clear(w http.ResponseWriter, opts CookieOptions) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Domain: opts.Domain,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: opts.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read pulls the raw JWT out of the request cookie. Returns "" if missing.
|
||||||
|
func Read(r *http.Request) string {
|
||||||
|
c, err := r.Cookie(CookieName)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package auth provides JWT issuance + verification for the BFF.
|
||||||
|
//
|
||||||
|
// Tokens carry these claims:
|
||||||
|
//
|
||||||
|
// sub user id (UUID string)
|
||||||
|
// tv token_version snapshot — middleware rejects if it doesn't match the
|
||||||
|
// current value on the user row. Bumping the user's token_version
|
||||||
|
// invalidates every JWT ever issued to that user (used on password
|
||||||
|
// change and forced logout).
|
||||||
|
// iat issued-at
|
||||||
|
// exp expiry
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims is the JWT payload we sign + verify.
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"sub"`
|
||||||
|
TokenVersion int `json:"tv"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer issues and verifies tokens with a single shared HMAC secret.
|
||||||
|
type Issuer struct {
|
||||||
|
secret []byte
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIssuer returns an Issuer configured with the given HMAC secret and TTL.
|
||||||
|
// The TTL is applied to every token issued via Issue().
|
||||||
|
func NewIssuer(secret string, ttl time.Duration) *Issuer {
|
||||||
|
return &Issuer{secret: []byte(secret), ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue mints a signed JWT for the given user.
|
||||||
|
func (i *Issuer) Issue(userID string, tokenVersion int) (string, time.Time, error) {
|
||||||
|
exp := time.Now().Add(i.ttl)
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
TokenVersion: tokenVersion,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(exp),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := tok.SignedString(i.secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
|
||||||
|
}
|
||||||
|
return signed, exp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidToken is returned by Parse for any verification failure.
|
||||||
|
var ErrInvalidToken = errors.New("invalid or expired token")
|
||||||
|
|
||||||
|
// Parse validates the token's signature + expiry and returns the claims.
|
||||||
|
// Callers must additionally compare claims.TokenVersion against the user's
|
||||||
|
// current token_version (the JWT alone can't tell us whether the user has
|
||||||
|
// since bumped their version).
|
||||||
|
func (i *Issuer) Parse(raw string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return i.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !tok.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTL returns the configured token lifetime. Handlers use it to set cookie
|
||||||
|
// Max-Age so the cookie and the JWT expire in lockstep.
|
||||||
|
func (i *Issuer) TTL() time.Duration { return i.ttl }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Package geoip resolves a request to an ISO-3166-1 alpha-2 country code,
|
||||||
|
// best-effort. Two sources, in priority order:
|
||||||
|
//
|
||||||
|
// 1. Cloudflare's CF-IPCountry header — instant, no network, present when
|
||||||
|
// the site is proxied through Cloudflare.
|
||||||
|
// 2. A GeoIP lookup on the client IP via the free ip-api.com endpoint —
|
||||||
|
// the fallback for when we're not behind Cloudflare (the current
|
||||||
|
// deployment is DNS-only).
|
||||||
|
//
|
||||||
|
// Everything is fire-and-forget: every failure path returns "" and the
|
||||||
|
// caller simply leaves the country unknown. Nothing here blocks a request
|
||||||
|
// on the critical path — callers run LookupCountryByIP from a goroutine.
|
||||||
|
package geoip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CountryFromCloudflare reads the CF-IPCountry header. Cloudflare sets it
|
||||||
|
// to a 2-letter code, or to a sentinel ("XX" unknown, "T1" Tor) we treat
|
||||||
|
// as absent. Returns "" when the header isn't a usable country.
|
||||||
|
func CountryFromCloudflare(r *http.Request) string {
|
||||||
|
cc := strings.ToUpper(strings.TrimSpace(r.Header.Get("CF-IPCountry")))
|
||||||
|
if len(cc) != 2 || cc == "XX" || cc == "T1" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIP returns the best guess at the caller's IP. The chi RealIP
|
||||||
|
// middleware already rewrites r.RemoteAddr from X-Forwarded-For /
|
||||||
|
// X-Real-IP, so we just strip the port.
|
||||||
|
func ClientIP(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||||
|
if err != nil {
|
||||||
|
// RemoteAddr may already be bare (no port) behind some proxies.
|
||||||
|
return strings.TrimSpace(r.RemoteAddr)
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupCountryByIP resolves an IP to a country code via ip-api.com.
|
||||||
|
// Returns "" for empty / private / loopback IPs (no point asking) and on
|
||||||
|
// any error, timeout, or non-success response. Respects ctx for
|
||||||
|
// cancellation/timeout — callers should pass a short-timeout context.
|
||||||
|
func LookupCountryByIP(ctx context.Context, ip string) string {
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed := net.ParseIP(ip)
|
||||||
|
if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() ||
|
||||||
|
parsed.IsLinkLocalUnicast() || parsed.IsUnspecified() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free tier is HTTP-only; countryCode keeps the payload tiny.
|
||||||
|
url := "http://ip-api.com/json/" + ip + "?fields=status,countryCode"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 4 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if body.Status != "success" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cc := strings.ToUpper(strings.TrimSpace(body.CountryCode))
|
||||||
|
if len(cc) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package mailer is a thin SMTP wrapper used by the auth flows.
|
||||||
|
//
|
||||||
|
// The stdlib `net/smtp` is sufficient for our needs (password reset +
|
||||||
|
// email verification — low volume, plain-MIME). We keep one interface
|
||||||
|
// (Mailer) so handlers can be tested with an in-memory implementation
|
||||||
|
// and so we can swap to a transactional provider later (Resend, SES)
|
||||||
|
// without touching the call sites.
|
||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mailer is the small surface auth services depend on.
|
||||||
|
type Mailer interface {
|
||||||
|
Send(to, subject, htmlBody string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config carries the per-environment SMTP knobs.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
From string // "Name <addr@host>" or just "addr@host"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured returns true if enough SMTP creds are present to send mail.
|
||||||
|
// Used by the auth service to decide whether to send-or-log.
|
||||||
|
func (c Config) Configured() bool { return c.Host != "" && c.From != "" }
|
||||||
|
|
||||||
|
// SMTP is the production Mailer. Connects per call (low volume → fine).
|
||||||
|
type SMTP struct {
|
||||||
|
cfg Config
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an SMTP mailer if creds are present, otherwise a NoopLogger
|
||||||
|
// that logs the message body to zap (useful in dev — the password-reset
|
||||||
|
// link shows up in your server logs).
|
||||||
|
func New(cfg Config, logger *zap.Logger) Mailer {
|
||||||
|
if !cfg.Configured() {
|
||||||
|
logger.Warn("SMTP not configured — mailer will log messages instead of sending")
|
||||||
|
return &NoopLogger{logger: logger}
|
||||||
|
}
|
||||||
|
return &SMTP{cfg: cfg, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send delivers a single HTML email. Plain-text alternative is omitted —
|
||||||
|
// the templates are short and modern clients render HTML fine.
|
||||||
|
func (m *SMTP) Send(to, subject, htmlBody string) error {
|
||||||
|
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
|
||||||
|
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||||
|
|
||||||
|
var msg strings.Builder
|
||||||
|
msg.WriteString("From: ")
|
||||||
|
msg.WriteString(m.cfg.From)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
msg.WriteString("To: ")
|
||||||
|
msg.WriteString(to)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
msg.WriteString("Subject: ")
|
||||||
|
msg.WriteString(subject)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
|
||||||
|
msg.WriteString(htmlBody)
|
||||||
|
|
||||||
|
// Most relays on port 587 want STARTTLS + AUTH. Port 465 = implicit TLS,
|
||||||
|
// not handled here — add only if needed.
|
||||||
|
if err := smtp.SendMail(addr, auth, fromAddr(m.cfg.From), []string{to}, []byte(msg.String())); err != nil {
|
||||||
|
return fmt.Errorf("smtp send: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromAddr extracts the bare address from a "Name <addr>" string. SendMail's
|
||||||
|
// `from` argument is the envelope-from, which must be the bare address.
|
||||||
|
func fromAddr(s string) string {
|
||||||
|
if i := strings.LastIndex(s, "<"); i >= 0 {
|
||||||
|
if j := strings.LastIndex(s, ">"); j > i {
|
||||||
|
return s[i+1 : j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoopLogger satisfies Mailer by writing the message into the application
|
||||||
|
// logger. Use it in development so you can copy reset links from the console.
|
||||||
|
type NoopLogger struct{ logger *zap.Logger }
|
||||||
|
|
||||||
|
func (n *NoopLogger) Send(to, subject, htmlBody string) error {
|
||||||
|
n.logger.Info("📧 mailer (not sent — SMTP unconfigured)",
|
||||||
|
zap.String("to", to),
|
||||||
|
zap.String("subject", subject),
|
||||||
|
zap.String("body", htmlBody))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Package oauth wraps the third-party sign-in providers used by the BFF.
|
||||||
|
//
|
||||||
|
// Design: each provider is a small struct that knows three things —
|
||||||
|
// - its golang.org/x/oauth2.Config (client id / secret / scopes / endpoint)
|
||||||
|
// - how to fetch a normalised Profile from an access token
|
||||||
|
// - its registry name ("google", "facebook")
|
||||||
|
//
|
||||||
|
// The handler layer is provider-agnostic: it picks a Registry entry by URL
|
||||||
|
// param, generates state, redirects to AuthCodeURL, then on callback
|
||||||
|
// exchanges the code and calls FetchProfile. The service layer then takes
|
||||||
|
// the Profile and turns it into a session (find-or-create user + JWT).
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
googleEndpoint "golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile is the normalised shape we extract from every provider.
|
||||||
|
//
|
||||||
|
// Email may be empty when the user denied the email scope (rare on Google,
|
||||||
|
// possible on Facebook accounts with no verified email). Callers must treat
|
||||||
|
// an empty email as a hard error — we use email as a fallback link key.
|
||||||
|
type Profile struct {
|
||||||
|
Provider string // "google" | "facebook"
|
||||||
|
ProviderUserID string // sub (Google) / id (Facebook)
|
||||||
|
Email string // lowercased
|
||||||
|
EmailVerified bool // Google sets this; Facebook does not — default true
|
||||||
|
Name string // display name, may be empty
|
||||||
|
AvatarURL string // absolute URL, may be empty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is the per-vendor adapter. Stateless after construction.
|
||||||
|
type Provider interface {
|
||||||
|
Name() string
|
||||||
|
Config(redirectURL string) *oauth2.Config
|
||||||
|
// AuthCodeOptions returns extra parameters appended to AuthCodeURL.
|
||||||
|
// Used by Facebook to set `auth_type=rerequest` so a user who
|
||||||
|
// previously declined the email permission gets re-prompted on the
|
||||||
|
// next sign-in attempt (Facebook silently re-uses prior declines
|
||||||
|
// otherwise). Most providers return nil.
|
||||||
|
AuthCodeOptions() []oauth2.AuthCodeOption
|
||||||
|
FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry maps provider name → Provider. Built from config at server start;
|
||||||
|
// only providers with both client ID and secret set become available.
|
||||||
|
type Registry map[string]Provider
|
||||||
|
|
||||||
|
// NewRegistry builds the live registry. Missing creds for a provider simply
|
||||||
|
// omit it — the start handler returns 404 for unconfigured providers.
|
||||||
|
func NewRegistry(cfg Credentials) Registry {
|
||||||
|
r := Registry{}
|
||||||
|
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
|
||||||
|
r["google"] = &google{id: cfg.GoogleClientID, secret: cfg.GoogleClientSecret}
|
||||||
|
}
|
||||||
|
if cfg.FacebookClientID != "" && cfg.FacebookClientSecret != "" {
|
||||||
|
r["facebook"] = &facebook{id: cfg.FacebookClientID, secret: cfg.FacebookClientSecret}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials is the subset of app config the registry needs.
|
||||||
|
type Credentials struct {
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
FacebookClientID string
|
||||||
|
FacebookClientSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has reports whether the registry knows the named provider.
|
||||||
|
func (r Registry) Has(name string) bool { _, ok := r[name]; return ok }
|
||||||
|
|
||||||
|
// Get returns the provider, or ErrUnknownProvider.
|
||||||
|
func (r Registry) Get(name string) (Provider, error) {
|
||||||
|
p, ok := r[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnknownProvider
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnknownProvider is returned when the URL param doesn't match any
|
||||||
|
// configured provider.
|
||||||
|
var ErrUnknownProvider = errors.New("unknown oauth provider")
|
||||||
|
|
||||||
|
// --- Google ---
|
||||||
|
|
||||||
|
type google struct{ id, secret string }
|
||||||
|
|
||||||
|
func (g *google) Name() string { return "google" }
|
||||||
|
|
||||||
|
func (g *google) Config(redirectURL string) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: g.id,
|
||||||
|
ClientSecret: g.secret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: []string{"openid", "email", "profile"},
|
||||||
|
Endpoint: googleEndpoint.Endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *google) AuthCodeOptions() []oauth2.AuthCodeOption { return nil }
|
||||||
|
|
||||||
|
// googleUserinfo is the subset of the v3 userinfo response we read. The
|
||||||
|
// full payload has more fields (locale, given_name, family_name) we don't
|
||||||
|
// currently need.
|
||||||
|
type googleUserinfo struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *google) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
|
||||||
|
const url = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
|
body, err := httpGet(ctx, tok, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("google userinfo: %w", err)
|
||||||
|
}
|
||||||
|
var u googleUserinfo
|
||||||
|
if err := json.Unmarshal(body, &u); err != nil {
|
||||||
|
return nil, fmt.Errorf("google userinfo decode: %w", err)
|
||||||
|
}
|
||||||
|
if u.Sub == "" {
|
||||||
|
return nil, fmt.Errorf("google userinfo: empty sub")
|
||||||
|
}
|
||||||
|
return &Profile{
|
||||||
|
Provider: "google",
|
||||||
|
ProviderUserID: u.Sub,
|
||||||
|
Email: strings.ToLower(strings.TrimSpace(u.Email)),
|
||||||
|
EmailVerified: u.EmailVerified,
|
||||||
|
Name: strings.TrimSpace(u.Name),
|
||||||
|
AvatarURL: u.Picture,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Facebook ---
|
||||||
|
|
||||||
|
type facebook struct{ id, secret string }
|
||||||
|
|
||||||
|
func (f *facebook) Name() string { return "facebook" }
|
||||||
|
|
||||||
|
// Bump in lockstep with whatever version your Meta app is configured for
|
||||||
|
// (Settings → Basic → API Version). Stay on a single supported version so
|
||||||
|
// /me responses match the documented schema.
|
||||||
|
const facebookGraphVersion = "v25.0"
|
||||||
|
|
||||||
|
func (f *facebook) Config(redirectURL string) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: f.id,
|
||||||
|
ClientSecret: f.secret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: []string{"email", "public_profile"},
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://www.facebook.com/" + facebookGraphVersion + "/dialog/oauth",
|
||||||
|
TokenURL: "https://graph.facebook.com/" + facebookGraphVersion + "/oauth/access_token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthCodeOptions: `auth_type=rerequest` forces Facebook to re-prompt for
|
||||||
|
// any permissions the user previously declined (specifically `email`).
|
||||||
|
// Without this, a user who unchecked Email on their first attempt would
|
||||||
|
// be stuck — Facebook silently re-grants the old decline forever.
|
||||||
|
func (f *facebook) AuthCodeOptions() []oauth2.AuthCodeOption {
|
||||||
|
return []oauth2.AuthCodeOption{
|
||||||
|
oauth2.SetAuthURLParam("auth_type", "rerequest"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// facebookMe captures the fields requested via ?fields=id,email,name,picture.
|
||||||
|
// picture is an envelope: { data: { url, ... } }.
|
||||||
|
type facebookMe struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture struct {
|
||||||
|
Data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"picture"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
|
||||||
|
// Facebook Graph API: pass the token as ?access_token=... (their
|
||||||
|
// canonical mode) rather than via Bearer header. Bearer works most
|
||||||
|
// of the time but is documented as a secondary mode and has been
|
||||||
|
// reported to silently drop fields in some Graph versions.
|
||||||
|
params := url.Values{
|
||||||
|
"fields": {"id,email,name,picture.type(large)"},
|
||||||
|
"access_token": {tok.AccessToken},
|
||||||
|
}
|
||||||
|
reqURL := "https://graph.facebook.com/" + facebookGraphVersion +
|
||||||
|
"/me?" + params.Encode()
|
||||||
|
|
||||||
|
body, err := plainGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("facebook /me: %w", err)
|
||||||
|
}
|
||||||
|
var u facebookMe
|
||||||
|
if err := json.Unmarshal(body, &u); err != nil {
|
||||||
|
return nil, fmt.Errorf("facebook /me decode: %w", err)
|
||||||
|
}
|
||||||
|
if u.ID == "" {
|
||||||
|
return nil, fmt.Errorf("facebook /me: empty id")
|
||||||
|
}
|
||||||
|
// If email permission was granted but FB still didn't return one,
|
||||||
|
// log the raw response so the operator can diagnose (commonly the
|
||||||
|
// user's FB account email is unverified — Graph API omits it silently).
|
||||||
|
if u.Email == "" {
|
||||||
|
log.Printf("oauth/facebook: /me returned no email; raw=%s", truncate(body, 512))
|
||||||
|
}
|
||||||
|
return &Profile{
|
||||||
|
Provider: "facebook",
|
||||||
|
ProviderUserID: u.ID,
|
||||||
|
Email: strings.ToLower(strings.TrimSpace(u.Email)),
|
||||||
|
EmailVerified: u.Email != "", // Facebook returns an email only if user granted the scope; we trust it
|
||||||
|
Name: strings.TrimSpace(u.Name),
|
||||||
|
AvatarURL: u.Picture.Data.URL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(b []byte, n int) string {
|
||||||
|
if len(b) <= n {
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
return string(b[:n]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainGet performs an unauthenticated GET (the caller is expected to have
|
||||||
|
// already embedded any access token in the URL). Used by Facebook because
|
||||||
|
// Graph API treats `?access_token=` as the canonical auth mode.
|
||||||
|
func plainGet(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpGet performs a Bearer-authenticated GET — used by Google. Facebook
|
||||||
|
// uses plainGet with the token in a query param instead.
|
||||||
|
func httpGet(ctx context.Context, tok *oauth2.Token, url string) ([]byte, error) {
|
||||||
|
cli := oauth2.NewClient(ctx, oauth2.StaticTokenSource(tok))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statePayload is the JSON shape we round-trip through the OAuth state
|
||||||
|
// parameter. The cookie holds the same encoded blob; on callback we compare
|
||||||
|
// the two byte-for-byte before trusting either.
|
||||||
|
//
|
||||||
|
// `Next` is the post-login redirect path on the frontend. We sign it (along
|
||||||
|
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
|
||||||
|
// user to an arbitrary URL after sign-in.
|
||||||
|
//
|
||||||
|
// `LinkUserID` is set only by the /link/start endpoint — it pins the OAuth
|
||||||
|
// callback to the user who initiated the link. An empty value means the
|
||||||
|
// callback should run the normal sign-in / sign-up flow instead.
|
||||||
|
type statePayload struct {
|
||||||
|
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
|
||||||
|
Next string `json:"u"` // safe path, validated by handler
|
||||||
|
IssuedAt int64 `json:"t"` // unix seconds
|
||||||
|
Provider string `json:"p"` // "google" | "facebook"
|
||||||
|
LinkUserID string `json:"l,omitempty"` // user id to link the provider to, empty for sign-in flow
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
|
||||||
|
// generous — typical OAuth round-trips finish in seconds. Longer than this
|
||||||
|
// and we discard the state to keep the replay window small.
|
||||||
|
const stateMaxAge = 10 * time.Minute
|
||||||
|
|
||||||
|
// EncodeState signs and packs a fresh state value bound to the given provider
|
||||||
|
// and post-login redirect path. The returned string is what goes into both
|
||||||
|
// the `state` query param and the cookie.
|
||||||
|
//
|
||||||
|
// `linkUserID` pins the callback to a specific user (link-to-current-user
|
||||||
|
// flow). Pass "" for the normal sign-in flow.
|
||||||
|
func EncodeState(secret []byte, provider, next, linkUserID string) (string, error) {
|
||||||
|
nonceB := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(nonceB); err != nil {
|
||||||
|
return "", fmt.Errorf("state nonce: %w", err)
|
||||||
|
}
|
||||||
|
p := statePayload{
|
||||||
|
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
|
||||||
|
Next: next,
|
||||||
|
IssuedAt: time.Now().Unix(),
|
||||||
|
Provider: provider,
|
||||||
|
LinkUserID: linkUserID,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
bodyEnc := base64.RawURLEncoding.EncodeToString(body)
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(bodyEnc))
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return bodyEnc + "." + sig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeState verifies the signature, checks the freshness window, and
|
||||||
|
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
|
||||||
|
// the handler maps it to a single error path.
|
||||||
|
//
|
||||||
|
// `linkUserID` is empty for sign-in flows and set for link-to-current-user
|
||||||
|
// flows — callers should branch on it.
|
||||||
|
func DecodeState(secret []byte, raw string) (provider, next, linkUserID string, err error) {
|
||||||
|
parts := strings.SplitN(raw, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
bodyEnc, sigEnc := parts[0], parts[1]
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(bodyEnc))
|
||||||
|
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
|
||||||
|
return "", "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
var p statePayload
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return "", "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
|
||||||
|
return "", "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
return p.Provider, p.Next, p.LinkUserID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidState is returned whenever signature or freshness checks fail.
|
||||||
|
// We deliberately return one error so handlers don't leak whether a request
|
||||||
|
// was tampered or just stale.
|
||||||
|
var ErrInvalidState = errors.New("invalid or expired oauth state")
|
||||||
Reference in New Issue
Block a user