16 Commits
65 changed files with 5435 additions and 184 deletions
+11 -1
View File
@@ -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=
+7 -11
View File
@@ -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
)
+5 -20
View File
@@ -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=
+46 -1
View File
@@ -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
View File
@@ -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) {
+70
View File
@@ -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)
}
+51
View File
@@ -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)
}
+309
View File
@@ -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
}
+101
View File
@@ -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
View File
@@ -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:
+190
View File
@@ -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 &amp; 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
View File
@@ -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"`
}
+33
View File
@@ -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 {
+37
View File
@@ -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
}
+105
View File
@@ -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
View File
@@ -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"`
+4 -3
View File
@@ -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
}
+119
View File
@@ -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()
}
+94
View File
@@ -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
}
+235 -62
View File
@@ -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
+308
View File
@@ -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
}
+84
View File
@@ -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()
}
+304 -35
View File
@@ -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
View File
@@ -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)
})
})
+467 -6
View File
@@ -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(
"&", "&amp;",
+356
View File
@@ -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
}
+189
View File
@@ -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>`))
+435
View File
@@ -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>`))
+137 -12
View File
@@ -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
}
+127 -8
View File
@@ -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);
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_feedback_created;
DROP TABLE IF EXISTS feedback;
+33
View File
@@ -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;
+6
View File
@@ -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;
+92
View File
@@ -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
}
+280
View File
@@ -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)
}
+103
View File
@@ -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")