Compare commits
16
Commits
feat/feed
...
feat/follow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa1eaf23b | ||
|
|
d9678ebb3b | ||
|
|
e9a96f1ff9 | ||
|
|
61b91c473b | ||
|
|
7f5b0fee11 | ||
|
|
3f6b7b6636 | ||
|
|
cf3a49458a | ||
|
|
ac95a857bd | ||
|
|
b895ceaccf | ||
|
|
59b3687e43 | ||
|
|
0a348593b1 | ||
|
|
72f4e6527c | ||
|
|
d3ad443fde | ||
|
|
0969887008 | ||
|
|
05b61cef09 | ||
|
|
430747536d |
+11
-1
@@ -25,13 +25,18 @@ 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)
|
||||
# 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
|
||||
FCM_CREDENTIALS_FILE=
|
||||
APNS_KEY_FILE=
|
||||
@@ -48,3 +53,8 @@ COOKIE_SECURE=false
|
||||
|
||||
# Frontend (CORS + email link base)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
FACEBOOK_OAUTH_CLIENT_ID=
|
||||
FACEBOOK_OAUTH_CLIENT_SECRET=
|
||||
@@ -3,23 +3,26 @@ module github.com/prabhupadasays/spsays-bff
|
||||
go 1.25.0
|
||||
|
||||
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/cors v1.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/typesense/typesense-go/v2 v2.0.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.1
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // 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
|
||||
@@ -27,14 +30,8 @@ require (
|
||||
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/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
||||
github.com/aws/smithy-go v1.26.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.5.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -49,7 +46,6 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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/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=
|
||||
@@ -5,12 +7,8 @@ github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGAL
|
||||
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/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
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/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
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=
|
||||
@@ -27,14 +25,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDb
|
||||
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/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
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=
|
||||
@@ -117,18 +107,16 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
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/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-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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
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=
|
||||
@@ -136,17 +124,14 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
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-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.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.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
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=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -36,13 +37,19 @@ type Config struct {
|
||||
R2Bucket string
|
||||
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
|
||||
|
||||
// SMTP — used for password-reset and email-verification mails.
|
||||
// SMTP — used for password-reset, email-verification, and
|
||||
// /feedback admin-notification mails.
|
||||
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
|
||||
@@ -58,6 +65,18 @@ type Config struct {
|
||||
|
||||
// Frontend
|
||||
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).
|
||||
@@ -95,6 +114,8 @@ func Load() (*Config, error) {
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
||||
|
||||
FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")),
|
||||
|
||||
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
|
||||
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
|
||||
APNsKeyID: getEnv("APNS_KEY_ID", ""),
|
||||
@@ -107,6 +128,13 @@ func Load() (*Config, error) {
|
||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||
|
||||
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", ""),
|
||||
}
|
||||
|
||||
if cfg.MongoURI == "" {
|
||||
@@ -147,3 +175,20 @@ func getEnvBool(key string, fallback bool) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
+161
-1
@@ -1,13 +1,16 @@
|
||||
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"
|
||||
)
|
||||
@@ -53,6 +56,21 @@ 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.
|
||||
@@ -79,11 +97,45 @@ func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
// LogIn authenticates an existing 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 {
|
||||
@@ -103,6 +155,40 @@ func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
@@ -172,6 +258,39 @@ func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -190,6 +309,47 @@ func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+105
-6
@@ -26,9 +26,36 @@ import (
|
||||
// PostHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PostHandler struct{ svc *service.PostService }
|
||||
type PostHandler struct {
|
||||
svc *service.PostService
|
||||
graph *service.SocialGraphService // post reporting
|
||||
}
|
||||
|
||||
func NewPostHandler(svc *service.PostService) *PostHandler { return &PostHandler{svc: svc} }
|
||||
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"`
|
||||
@@ -59,9 +86,37 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
p, err := h.svc.Get(r.Context(), 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
|
||||
@@ -386,10 +441,11 @@ 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) *ProfileHandler {
|
||||
return &ProfileHandler{users: users, posts: posts, collections: collections}
|
||||
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) {
|
||||
@@ -409,18 +465,36 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// 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)
|
||||
@@ -429,6 +503,29 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -446,6 +543,8 @@ func mapSocialErr(w http.ResponseWriter, err error) {
|
||||
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:
|
||||
|
||||
@@ -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>`
|
||||
+122
-2
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
@@ -29,9 +30,20 @@ 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 name, avatar, and/or handle.
|
||||
// UpdateProfile patches any subset of the editable profile fields.
|
||||
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
@@ -41,7 +53,19 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL, req.Handle)
|
||||
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")
|
||||
@@ -53,6 +77,102 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -61,6 +61,39 @@ func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Hand
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth is the soft-auth sibling of RequireAuth. Same logic, but
|
||||
// invalid / missing sessions don't reject the request — they just skip
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,11 @@ 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"`
|
||||
@@ -19,6 +24,21 @@ type Post struct {
|
||||
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.
|
||||
@@ -66,3 +86,88 @@ type PublicProfile struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
+75
-4
@@ -1,30 +1,81 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
// Email is nullable: OAuth users (typically Facebook accounts with no
|
||||
// verified email on file) sign up without one and can fill it in later
|
||||
// from their profile.
|
||||
Email *string `json:"email"`
|
||||
Handle string `json:"handle"`
|
||||
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:"-"`
|
||||
PasswordHash *string `json:"-"`
|
||||
TokenVersion int `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||
// "Change password" vs "Set a password" for OAuth-only accounts, without
|
||||
// the password hash itself ever leaving the server.
|
||||
func (u User) MarshalJSON() ([]byte, error) {
|
||||
type alias User
|
||||
return json.Marshal(&struct {
|
||||
alias
|
||||
HasPassword bool `json:"has_password"`
|
||||
}{
|
||||
alias: alias(u),
|
||||
HasPassword: u.PasswordHash != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// AuthToken represents a row in the `auth_tokens` table.
|
||||
// Used for both password reset and email verification flows.
|
||||
type AuthToken struct {
|
||||
@@ -40,8 +91,28 @@ type AuthToken struct {
|
||||
const (
|
||||
PurposePasswordReset = "password_reset"
|
||||
PurposeEmailVerification = "email_verification"
|
||||
// PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
|
||||
// account has been unverified for >30 days. Verification happens
|
||||
// per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
|
||||
// offline by hash collision — only online against rate limits.
|
||||
PurposeLoginOTP = "login_otp"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type ShortURL struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -200,7 +200,10 @@ func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, user
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// PostsIn returns the posts (with images + author) inside a collection.
|
||||
// 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, ''),
|
||||
@@ -234,8 +237,6 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate collection posts: %w", err)
|
||||
}
|
||||
// Defer image hydration to caller via PostRepo if needed; for v1 we just
|
||||
// return without images here (the management page can fetch separately).
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,10 +13,53 @@ import (
|
||||
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) {
|
||||
@@ -57,12 +100,9 @@ func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourc
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ReplaceImages atomically swaps a post's image set. Ownership is
|
||||
// enforced inside the tx by locking the post row and checking user_id —
|
||||
// non-owners get ErrPostNotFound (we don't distinguish "not yours" from
|
||||
// "doesn't exist" to non-owners). Existing rows are wiped, the new URLs
|
||||
// are inserted with sequential positions, and the post's `updated_at`
|
||||
// is touched so list views can sort by recency if needed later.
|
||||
// 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 {
|
||||
@@ -98,27 +138,18 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// FindByID returns one post with its images and author info.
|
||||
// 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) {
|
||||
const q = `
|
||||
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 posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = $1`
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
|
||||
p := &model.Post{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(
|
||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
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})
|
||||
imgs, err := r.ImagesFor(ctx, []string{p.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -126,46 +157,39 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListByHandle returns the public feed for a user, newest-first, paginated.
|
||||
// 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 total int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE u.handle = $1`, handle,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||
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)
|
||||
}
|
||||
|
||||
const q = `
|
||||
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 posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE u.handle = $1
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, handle, limit, offset)
|
||||
return r.ListByUser(ctx, userID, limit, offset)
|
||||
}
|
||||
|
||||
// ListByUser returns the user's own posts (private listing — same shape).
|
||||
// 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) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||
total, err := r.CountByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
const q = `
|
||||
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 posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY p.created_at DESC
|
||||
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.
|
||||
// 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)
|
||||
@@ -178,17 +202,167 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUser is used by services that need to know how many posts a user
|
||||
// has — currently the public-profile endpoint only.
|
||||
// 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)
|
||||
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 user posts: %w", err)
|
||||
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) {
|
||||
@@ -202,11 +376,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
||||
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 {
|
||||
if err := scanPost(rows, &p); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan post: %w", err)
|
||||
}
|
||||
posts = append(posts, p)
|
||||
@@ -217,7 +387,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
imgs, err := r.imagesFor(ctx, ids)
|
||||
imgs, err := r.ImagesFor(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -228,9 +398,12 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
||||
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.
|
||||
func (r *PostRepo) imagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
|
||||
// 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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -29,21 +29,62 @@ 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) {
|
||||
const q = `
|
||||
q := `
|
||||
INSERT INTO users (email, password_hash, name, handle)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
RETURNING ` + userColumns
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q,
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
||||
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
|
||||
).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)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "users_handle_key") {
|
||||
return nil, ErrHandleTaken
|
||||
@@ -62,6 +103,39 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User,
|
||||
`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)
|
||||
@@ -74,15 +148,10 @@ func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User
|
||||
}
|
||||
|
||||
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
|
||||
q := `
|
||||
SELECT id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at
|
||||
FROM users ` + where + ` LIMIT 1`
|
||||
q := `SELECT ` + userColumns + ` FROM users ` + where + ` LIMIT 1`
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, args...).
|
||||
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)
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
@@ -92,35 +161,107 @@ func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*mod
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
|
||||
// leave a field unchanged. Returns the updated user.
|
||||
// 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, name, avatarURL, handle *string) (*model.User, error) {
|
||||
const q = `
|
||||
UPDATE users
|
||||
SET name = COALESCE($2, name),
|
||||
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
|
||||
handle = COALESCE($5, handle),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) {
|
||||
sets := make([]string, 0, 10)
|
||||
args := []any{id}
|
||||
|
||||
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
|
||||
// If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which
|
||||
// may itself be the empty string — caller decides if "" should map to NULL).
|
||||
updateAvatar := avatarURL != nil
|
||||
var avatarVal *string
|
||||
if updateAvatar && *avatarURL != "" {
|
||||
avatarVal = avatarURL
|
||||
// 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 := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal, handle).
|
||||
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)
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
@@ -133,6 +274,25 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL
|
||||
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 {
|
||||
@@ -152,6 +312,89 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
|
||||
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()
|
||||
@@ -223,6 +466,32 @@ func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string
|
||||
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 {
|
||||
|
||||
+175
-12
@@ -16,6 +16,7 @@ import (
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -45,11 +46,16 @@ type Server struct {
|
||||
searchH *handler.SearchHandler
|
||||
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
|
||||
@@ -119,6 +125,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
|
||||
userRepo := repository.NewUserRepo(s.pg)
|
||||
tokenRepo := repository.NewAuthTokenRepo(s.pg)
|
||||
oauthRepo := repository.NewOAuthAccountRepo(s.pg)
|
||||
|
||||
r2, err := repository.NewR2(repository.R2Config{
|
||||
AccountID: cfg.R2AccountID,
|
||||
@@ -143,7 +150,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
From: cfg.SMTPFrom,
|
||||
}, logger)
|
||||
|
||||
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||
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.
|
||||
@@ -160,10 +167,50 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
s.userRepo = userRepo
|
||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||
s.userH = handler.NewUserHandler(userSvc)
|
||||
s.postH = handler.NewPostHandler(postSvc)
|
||||
|
||||
// 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)
|
||||
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo)
|
||||
|
||||
// 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.
|
||||
@@ -276,6 +323,19 @@ func (s *Server) setupRouter() {
|
||||
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
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Books
|
||||
@@ -463,28 +523,60 @@ func (s *Server) setupRouter() {
|
||||
}
|
||||
r.Post("/signup", s.authH.SignUp)
|
||||
r.Post("/login", s.authH.LogIn)
|
||||
r.Post("/verify-login-otp", s.authH.VerifyLoginOTP)
|
||||
r.Post("/logout", s.authH.LogOut)
|
||||
r.Post("/forgot-password", s.authH.ForgotPassword)
|
||||
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 + avatar — all protected.
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,9 +586,15 @@ func (s *Server) setupRouter() {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Public — read individual posts without auth.
|
||||
r.Get("/{id}", s.postH.Get)
|
||||
// Protected — create / delete / image presign / "my posts".
|
||||
// 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)
|
||||
@@ -504,6 +602,13 @@ func (s *Server) setupRouter() {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -522,12 +627,18 @@ func (s *Server) setupRouter() {
|
||||
// --- 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.Get("/{id}", s.collectionH.Get)
|
||||
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)
|
||||
@@ -543,13 +654,42 @@ func (s *Server) setupRouter() {
|
||||
})
|
||||
|
||||
// --- 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
|
||||
}
|
||||
r.Get("/{handle}", s.profileH.Get)
|
||||
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||
// 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.
|
||||
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
||||
@@ -568,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) {
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -39,6 +40,7 @@ var (
|
||||
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
|
||||
@@ -49,10 +51,12 @@ type AuthService struct {
|
||||
EmailVerifyTTL time.Duration
|
||||
}
|
||||
|
||||
// NewAuthService wires the dependencies.
|
||||
// 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,
|
||||
@@ -61,6 +65,7 @@ func NewAuthService(
|
||||
return &AuthService{
|
||||
users: users,
|
||||
tokens: tokens,
|
||||
oauth: oauthRepo,
|
||||
jwt: jwt,
|
||||
mail: mail,
|
||||
logger: logger,
|
||||
@@ -70,6 +75,19 @@ func NewAuthService(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -125,10 +143,32 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
||||
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.
|
||||
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
|
||||
// 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) {
|
||||
@@ -136,12 +176,412 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
// 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.
|
||||
@@ -179,9 +619,15 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) er
|
||||
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 {
|
||||
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
|
||||
@@ -256,6 +702,12 @@ func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -268,7 +720,7 @@ func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User)
|
||||
|
||||
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)
|
||||
return s.mail.Send(*u.Email, "Verify your email — Prabhupāda Says", body)
|
||||
}
|
||||
|
||||
// newToken returns (rawHex, sha256HexHash). The raw value goes in the
|
||||
@@ -345,6 +797,15 @@ clicking the link below:</p>
|
||||
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(
|
||||
"&", "&",
|
||||
|
||||
@@ -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,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>`))
|
||||
@@ -61,9 +61,82 @@ func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, so
|
||||
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
|
||||
}
|
||||
|
||||
// Get returns one post (public).
|
||||
func (s *PostService) Get(ctx context.Context, id string) (*model.Post, error) {
|
||||
return s.posts.FindByID(ctx, id)
|
||||
// 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.
|
||||
@@ -71,9 +144,9 @@ 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. The caller must own the
|
||||
// post. Returns the fresh post payload so the client can reconcile its
|
||||
// local state without a separate fetch.
|
||||
// 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
|
||||
@@ -81,19 +154,31 @@ func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string
|
||||
return s.posts.FindByID(ctx, postID)
|
||||
}
|
||||
|
||||
// ListByHandle is the public feed view.
|
||||
func (s *PostService) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||
// 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
|
||||
}
|
||||
return s.posts.ListByHandle(ctx, handle, limit, offset)
|
||||
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 posts (drafts feature isn't a
|
||||
// thing yet, so this is equivalent to ListByHandle minus the handle lookup).
|
||||
// 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
|
||||
@@ -101,7 +186,16 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.posts.ListByUser(ctx, userID, limit, offset)
|
||||
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.
|
||||
@@ -320,6 +414,37 @@ func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*mode
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -35,28 +35,147 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
|
||||
// `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.
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
|
||||
if name != nil {
|
||||
trimmed := strings.TrimSpace(*name)
|
||||
// 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)")
|
||||
}
|
||||
name = &trimmed
|
||||
p.Name = &trimmed
|
||||
}
|
||||
if handle != nil {
|
||||
clean := normalizeHandle(*handle)
|
||||
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)")
|
||||
}
|
||||
handle = &clean
|
||||
p.Handle = &clean
|
||||
}
|
||||
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
|
||||
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
|
||||
|
||||
@@ -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,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,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