feat: social graph features
This commit is contained in:
@@ -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"
|
||||
)
|
||||
@@ -94,10 +97,37 @@ 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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// NotificationHandler serves the signed-in user's notification feed +
|
||||
// unread badge count, and marks them read. All routes sit behind
|
||||
// RequireAuth, so middleware.UserID is always populated.
|
||||
type NotificationHandler struct{ svc *service.NotificationService }
|
||||
|
||||
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List returns the caller's notifications, newest first.
|
||||
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
items, err := h.svc.List(r.Context(), uid, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load notifications")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// UnreadCount returns {"count": N} for the bell badge.
|
||||
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
n, err := h.svc.UnreadCount(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load unread count")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]int{"count": n})
|
||||
}
|
||||
|
||||
// MarkAllRead marks every notification read (called when the page opens).
|
||||
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.MarkAllRead(r.Context(), uid); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not mark notifications read")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -414,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) {
|
||||
@@ -437,12 +465,27 @@ 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,
|
||||
})
|
||||
}
|
||||
@@ -500,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:
|
||||
|
||||
@@ -30,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())
|
||||
|
||||
@@ -42,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")
|
||||
|
||||
@@ -86,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"`
|
||||
}
|
||||
|
||||
@@ -26,6 +26,35 @@ type User struct {
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
Plan string `json:"plan"` // "free" | "premium"
|
||||
// Country is the account's ISO-3166-1 alpha-2 code (e.g. "IN"),
|
||||
// captured best-effort at signup and editable from the profile form.
|
||||
// nil until known.
|
||||
Country *string `json:"country"`
|
||||
|
||||
// --- Devotional profile (user-editable) ---
|
||||
// Gender is a free-but-constrained string ("male"|"female"|"other"),
|
||||
// nil when unspecified.
|
||||
Gender *string `json:"gender"`
|
||||
// Sampradaya is the devotee's spiritual lineage (a lowercase code,
|
||||
// e.g. "gaudiya"). Optional — new accounts default to "gaudiya" but it
|
||||
// can be cleared, so nil means "not specified".
|
||||
Sampradaya *string `json:"sampradaya"`
|
||||
// Initiated marks whether the devotee has received initiation.
|
||||
Initiated bool `json:"initiated"`
|
||||
// InitiatedAt is the initiation date (DATE column), nil when unknown or
|
||||
// not initiated.
|
||||
InitiatedAt *time.Time `json:"initiated_at"`
|
||||
// DailyChantingRounds is the devotee's daily japa round vow (1–1008),
|
||||
// nil when unspecified.
|
||||
DailyChantingRounds *int `json:"daily_chanting_rounds"`
|
||||
|
||||
// --- Notification scheduling (system-managed; no UI yet) ---
|
||||
// PreferredChantingTime / PeakActiveTime store a local clock time as
|
||||
// "HH:MM". They're written silently by the backend to time automated
|
||||
// notifications; the scheduling logic ships later. nil until computed.
|
||||
PreferredChantingTime *string `json:"preferred_chanting_time"`
|
||||
PeakActiveTime *string `json:"peak_active_time"`
|
||||
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||
PasswordHash *string `json:"-"`
|
||||
TokenVersion int `json:"-"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -68,11 +109,10 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User,
|
||||
// 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) {
|
||||
const q = `
|
||||
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 id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
RETURNING ` + userColumns
|
||||
var normEmail *string
|
||||
if email != nil {
|
||||
v := strings.ToLower(strings.TrimSpace(*email))
|
||||
@@ -81,10 +121,9 @@ func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle
|
||||
}
|
||||
}
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q,
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
||||
normEmail, name, handle, avatarURL, emailVerified,
|
||||
).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
|
||||
@@ -109,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
|
||||
@@ -135,19 +169,16 @@ func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*mod
|
||||
// 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) {
|
||||
const q = `
|
||||
q := `
|
||||
UPDATE users
|
||||
SET email = $2,
|
||||
email_verified_at = NULL,
|
||||
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`
|
||||
RETURNING ` + userColumns
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))).
|
||||
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, id, strings.ToLower(strings.TrimSpace(email))), u)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
@@ -160,35 +191,77 @@ func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User,
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
|
||||
// leave a field unchanged. Returns the updated user.
|
||||
// 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
|
||||
@@ -201,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 {
|
||||
@@ -240,9 +332,7 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.Use
|
||||
substring := "%" + cleaned + "%"
|
||||
prefix := cleaned + "%"
|
||||
|
||||
const sql = `
|
||||
SELECT id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at
|
||||
sql := `SELECT ` + userColumns + `
|
||||
FROM users
|
||||
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
||||
ORDER BY
|
||||
@@ -262,11 +352,7 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.Use
|
||||
out := []model.User{}
|
||||
for rows.Next() {
|
||||
var u model.User
|
||||
if err := rows.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 != nil {
|
||||
if err := scanUser(rows, &u); err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
|
||||
@@ -52,6 +52,8 @@ type Server struct {
|
||||
bookmarkH *handler.BookmarkHandler
|
||||
collectionH *handler.CollectionHandler
|
||||
profileH *handler.ProfileHandler
|
||||
relationH *handler.RelationshipHandler
|
||||
notificationH *handler.NotificationHandler
|
||||
feedbackH *handler.FeedbackHandler
|
||||
statsH *handler.StatsHandler
|
||||
|
||||
@@ -184,10 +186,23 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
} else {
|
||||
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
|
||||
}
|
||||
s.postH = handler.NewPostHandler(postSvc)
|
||||
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.
|
||||
@@ -592,6 +607,8 @@ func (s *Server) setupRouter() {
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -646,10 +663,34 @@ func (s *Server) setupRouter() {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Public reads (anonymous-OK; OptionalAuth populates viewer_id
|
||||
// so Get can include the viewer's relationship + the feed can
|
||||
// carry viewer_has_reshared).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/{handle}", s.profileH.Get)
|
||||
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||
})
|
||||
|
||||
// Relationship actions — follow/block/report. All require a
|
||||
// signed-in viewer; each returns the recomputed relationship
|
||||
// + the target's follower/following counts. Follows are
|
||||
// request-based: POST /follow creates a PENDING request that
|
||||
// the target later accepts or rejects.
|
||||
if s.relationH != nil {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/{handle}/follow", s.relationH.Follow)
|
||||
r.Delete("/{handle}/follow", s.relationH.Unfollow)
|
||||
r.Post("/{handle}/follow/accept", s.relationH.AcceptFollow)
|
||||
r.Post("/{handle}/follow/reject", s.relationH.RejectFollow)
|
||||
r.Delete("/{handle}/follower", s.relationH.RemoveFollower)
|
||||
r.Post("/{handle}/block", s.relationH.Block)
|
||||
r.Delete("/{handle}/block", s.relationH.Unblock)
|
||||
r.Post("/{handle}/report", s.relationH.Report)
|
||||
})
|
||||
}
|
||||
})
|
||||
// URL Shortener — Postgres-backed, Redis-queued.
|
||||
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
||||
r.Route("/share", func(r chi.Router) {
|
||||
@@ -681,9 +722,18 @@ func (s *Server) setupRouter() {
|
||||
r.Post("/feedback", s.feedbackH.Submit)
|
||||
})
|
||||
|
||||
// Push Notifications
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -75,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.
|
||||
|
||||
@@ -281,7 +281,7 @@ func derefOr(p *string, fallback string) string {
|
||||
// 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:Georgia,'Times New Roman',serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
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
|
||||
@@ -302,7 +302,7 @@ var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Pa
|
||||
// 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:Georgia,'Times New Roman',serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package service — NotificationService is the single seam every
|
||||
// user-facing event flows through ("the notification plugin"). Today it
|
||||
// does two things per event:
|
||||
//
|
||||
// 1. Persists a row in `notifications` (so an in-app notification center
|
||||
// and, later, websocket + push delivery can read the same history).
|
||||
// 2. Sends an email to the recipient via the shared mailer.
|
||||
//
|
||||
// Everything here is fire-and-forget from the caller's perspective: a
|
||||
// follow request must succeed even if the email bounces or the insert
|
||||
// fails, so Notify* never returns an error — failures are logged. When
|
||||
// websocket/push land they hook in right here alongside the email send.
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Notification kinds — short machine strings stored in notifications.kind.
|
||||
const (
|
||||
NotifFollowRequest = "follow_request"
|
||||
NotifFollowAccepted = "follow_accepted"
|
||||
)
|
||||
|
||||
type NotificationService struct {
|
||||
notifs *repository.NotificationRepo
|
||||
users *repository.UserRepo
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *NotificationService {
|
||||
return &NotificationService{
|
||||
notifs: notifs,
|
||||
users: users,
|
||||
mail: mail,
|
||||
logger: logger,
|
||||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns the caller's notifications, newest first.
|
||||
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.notifs.ListByUser(ctx, userID, limit, offset)
|
||||
}
|
||||
|
||||
// UnreadCount returns the caller's unread notification count (for the bell
|
||||
// badge).
|
||||
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
|
||||
return s.notifs.CountUnread(ctx, userID)
|
||||
}
|
||||
|
||||
// MarkAllRead marks all of the caller's notifications read.
|
||||
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
|
||||
return s.notifs.MarkAllRead(ctx, userID)
|
||||
}
|
||||
|
||||
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
|
||||
// them. Persists the row and emails the recipient (if they have an email
|
||||
// on file).
|
||||
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
|
||||
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
|
||||
"%s requested to follow you — Prabhupāda Says")
|
||||
}
|
||||
|
||||
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
|
||||
// follow request (i.e. the recipient can now see actor's activity).
|
||||
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
|
||||
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
|
||||
"%s accepted your follow request — Prabhupāda Says")
|
||||
}
|
||||
|
||||
// dispatch persists the notification and sends the matching email. All
|
||||
// failures are logged, never propagated.
|
||||
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
|
||||
actor := actorID
|
||||
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
|
||||
s.logger.Warn("persist notification failed",
|
||||
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||||
// Persistence failed, but still try to email — the email is the
|
||||
// only delivery channel that exists today.
|
||||
}
|
||||
|
||||
recipient, err := s.users.FindByID(ctx, recipientID)
|
||||
if err != nil {
|
||||
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
||||
// No email on file (OAuth-only account that never added one) —
|
||||
// the persisted row is the notification; nothing to send.
|
||||
return
|
||||
}
|
||||
actorUser, err := s.users.FindByID(ctx, actorID)
|
||||
if err != nil {
|
||||
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := s.render(kind, recipient, actorUser)
|
||||
if err != nil {
|
||||
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
|
||||
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
|
||||
s.logger.Warn("notification email send failed",
|
||||
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
|
||||
data := map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
"ActorName": actorDisplay(actor),
|
||||
"ActorHandle": actor.Handle,
|
||||
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
|
||||
}
|
||||
var tpl *template.Template
|
||||
switch kind {
|
||||
case NotifFollowRequest:
|
||||
tpl = followRequestEmailTpl
|
||||
case NotifFollowAccepted:
|
||||
tpl = followAcceptedEmailTpl
|
||||
default:
|
||||
tpl = followRequestEmailTpl
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// actorDisplay prefers the display name, falling back to @handle.
|
||||
func actorDisplay(u *model.User) string {
|
||||
if n := strings.TrimSpace(u.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
return "@" + u.Handle
|
||||
}
|
||||
|
||||
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
|
||||
// the actor and link to their profile (where the recipient can accept a
|
||||
// pending request).
|
||||
var followRequestEmailTpl = template.Must(template.New("followRequest").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
|
||||
has requested to follow you on Prabhupāda Says.</p>
|
||||
|
||||
<p>You can review and accept or decline the request from their profile:</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
|
||||
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
@@ -0,0 +1,435 @@
|
||||
// Package service — SocialGraphService is the follow / block / report
|
||||
// brain. It sits over FollowRepo + BlockRepo + ReportRepo, resolves the
|
||||
// target by handle, enforces the rules (no self-follow, no following
|
||||
// across a block), keeps the request-based follow lifecycle honest, and
|
||||
// routes every notable event through the NotificationService.
|
||||
//
|
||||
// Follows are request-based end to end: RequestFollow only ever creates a
|
||||
// PENDING edge; the target turns it into a real follow via AcceptFollow.
|
||||
// Every mutating method returns the freshly-recomputed RelationshipResult
|
||||
// (the viewer's state toward the target + the target's follower/following
|
||||
// counts) so the frontend can update its buttons and stat line from one
|
||||
// response.
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Sentinels specific to the social graph (ErrForbidden / ErrNotFound are
|
||||
// shared from social_service.go in this package).
|
||||
var (
|
||||
// ErrBlocked — the action can't proceed because one of the two users
|
||||
// has blocked the other.
|
||||
ErrBlocked = errors.New("blocked")
|
||||
// ErrSelfRelation — you can't follow / block / report yourself.
|
||||
ErrSelfRelation = errors.New("cannot perform this action on yourself")
|
||||
)
|
||||
|
||||
// reportCategories is the allow-list the Report flow validates against —
|
||||
// mirrored by the frontend's report dialog dropdown.
|
||||
var reportCategories = map[string]bool{
|
||||
"spam": true,
|
||||
"harassment": true,
|
||||
"impersonation": true,
|
||||
"inappropriate": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
// postReportCategories is the allow-list for reporting a post — mirrored
|
||||
// by the frontend's post report dialog.
|
||||
var postReportCategories = map[string]bool{
|
||||
"spam": true,
|
||||
"harassment": true,
|
||||
"misinformation": true,
|
||||
"inappropriate": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
// RelationshipResult is the payload every relationship mutation returns:
|
||||
// the viewer's relationship to the target plus the target's live counts.
|
||||
type RelationshipResult struct {
|
||||
Relationship *model.RelationshipState `json:"relationship"`
|
||||
FollowerCount int `json:"follower_count"`
|
||||
FollowingCount int `json:"following_count"`
|
||||
}
|
||||
|
||||
type SocialGraphService struct {
|
||||
follows *repository.FollowRepo
|
||||
blocks *repository.BlockRepo
|
||||
reports *repository.ReportRepo
|
||||
posts *repository.PostRepo
|
||||
users *repository.UserRepo
|
||||
notif *NotificationService
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSocialGraphService(
|
||||
follows *repository.FollowRepo,
|
||||
blocks *repository.BlockRepo,
|
||||
reports *repository.ReportRepo,
|
||||
posts *repository.PostRepo,
|
||||
users *repository.UserRepo,
|
||||
notif *NotificationService,
|
||||
mail mailer.Mailer,
|
||||
admins []string,
|
||||
logger *zap.Logger,
|
||||
) *SocialGraphService {
|
||||
return &SocialGraphService{
|
||||
follows: follows, blocks: blocks, reports: reports, posts: posts,
|
||||
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// resolve looks up the target by handle and rejects self-targeting.
|
||||
func (s *SocialGraphService) resolve(ctx context.Context, viewerID, handle string) (*model.User, error) {
|
||||
t, err := s.users.FindByHandle(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, err // ErrUserNotFound → 404 via mapSocialErr
|
||||
}
|
||||
if t.ID == viewerID {
|
||||
return nil, ErrSelfRelation
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// RequestFollow inserts a pending follow edge viewer→target and notifies
|
||||
// the target. Refuses if either party has blocked the other. Idempotent:
|
||||
// re-requesting an existing edge doesn't re-notify.
|
||||
func (s *SocialGraphService) RequestFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewerBlocked, targetBlocked, err := s.blocks.BlockState(ctx, viewerID, t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if viewerBlocked || targetBlocked {
|
||||
return nil, ErrBlocked
|
||||
}
|
||||
created, err := s.follows.Request(ctx, viewerID, t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if created {
|
||||
s.notif.NotifyFollowRequest(ctx, t.ID, viewerID)
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Unfollow removes the viewer→target edge — covers both "stop following"
|
||||
// (accepted) and "cancel my pending request". Idempotent.
|
||||
func (s *SocialGraphService) Unfollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.follows.Delete(ctx, viewerID, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// AcceptFollow accepts the target's pending request to follow the viewer
|
||||
// (edge target→viewer). On the pending→accepted transition the target is
|
||||
// notified that their request went through. `handle` is the requester.
|
||||
func (s *SocialGraphService) AcceptFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accepted, err := s.follows.Accept(ctx, t.ID, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if accepted {
|
||||
s.notif.NotifyFollowAccepted(ctx, t.ID, viewerID)
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// RejectFollow declines the target's pending request to follow the viewer
|
||||
// by deleting the target→viewer edge. Idempotent; no notification (a
|
||||
// silent decline is kinder than an explicit rejection email).
|
||||
func (s *SocialGraphService) RejectFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// RemoveFollower drops the target from the viewer's followers by deleting
|
||||
// the target→viewer edge. Mechanically the same as RejectFollow but
|
||||
// distinct in intent (an accepted follower is being removed). Idempotent.
|
||||
func (s *SocialGraphService) RemoveFollower(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Block records viewer→target and severs any follow edges both ways.
|
||||
func (s *SocialGraphService) Block(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.blocks.Block(ctx, viewerID, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Unblock removes the viewer→target block.
|
||||
func (s *SocialGraphService) Unblock(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.blocks.Unblock(ctx, viewerID, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Report files a report against the target and emails the admins. The
|
||||
// category must be in the allow-list and the reason non-empty.
|
||||
func (s *SocialGraphService) Report(ctx context.Context, viewerID, handle, category, reason string) error {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if !reportCategories[category] {
|
||||
return errors.New("invalid report category")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return errors.New("reason is required")
|
||||
}
|
||||
if len(reason) > 2000 {
|
||||
return errors.New("reason too long (max 2000)")
|
||||
}
|
||||
if _, err := s.reports.Create(ctx, viewerID, t.ID, category, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
// Email the admins — fire-and-forget; the row is already persisted.
|
||||
reporter, rerr := s.users.FindByID(ctx, viewerID)
|
||||
if rerr != nil {
|
||||
s.logger.Warn("report saved but reporter lookup failed; emailing without reporter info",
|
||||
zap.String("reporter", viewerID), zap.Error(rerr))
|
||||
}
|
||||
s.notifyAdminsOfReport(reporter, t, category, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReportPost files a report against a specific post and emails the admins.
|
||||
// The category must be in the post allow-list; the reason is optional.
|
||||
func (s *SocialGraphService) ReportPost(ctx context.Context, reporterID, postID, category, reason string) error {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if !postReportCategories[category] {
|
||||
return errors.New("invalid report category")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len(reason) > 2000 {
|
||||
return errors.New("reason too long (max 2000)")
|
||||
}
|
||||
post, err := s.posts.FindByID(ctx, postID)
|
||||
if err != nil {
|
||||
return err // ErrPostNotFound → 404 via mapSocialErr
|
||||
}
|
||||
if _, err := s.reports.CreatePostReport(ctx, reporterID, post.ID, category, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
reporter, rerr := s.users.FindByID(ctx, reporterID)
|
||||
if rerr != nil {
|
||||
s.logger.Warn("post report saved but reporter lookup failed; emailing without reporter info",
|
||||
zap.String("reporter", reporterID), zap.Error(rerr))
|
||||
}
|
||||
s.notifyAdminsOfPostReport(reporter, post, category, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relationship computes the viewer's relationship toward targetID.
|
||||
func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetID string) (*model.RelationshipState, error) {
|
||||
out := &model.RelationshipState{}
|
||||
outStatus, err := s.follows.Status(ctx, viewerID, targetID) // viewer → target
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inStatus, err := s.follows.Status(ctx, targetID, viewerID) // target → viewer
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Following = outStatus == "accepted"
|
||||
out.FollowRequested = outStatus == "pending"
|
||||
out.FollowsYou = inStatus == "accepted"
|
||||
out.RequestsYou = inStatus == "pending"
|
||||
|
||||
viewerBlocked, blockedByTarget, err := s.blocks.BlockState(ctx, viewerID, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Blocked = viewerBlocked
|
||||
out.BlockedBy = blockedByTarget
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Counts returns the accepted-follower and accepted-following totals for
|
||||
// a user.
|
||||
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
|
||||
followers, err = s.follows.CountFollowers(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
following, err = s.follows.CountFollowing(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return followers, following, nil
|
||||
}
|
||||
|
||||
// ProfileExtras is the read used by the public-profile endpoint: the
|
||||
// target's follower/following counts plus, when a non-owner viewer is
|
||||
// signed in, their relationship to the target. rel is nil for anonymous
|
||||
// viewers and for the owner viewing their own profile.
|
||||
func (s *SocialGraphService) ProfileExtras(ctx context.Context, viewerID string, target *model.User) (followers, following int, rel *model.RelationshipState, err error) {
|
||||
followers, following, err = s.Counts(ctx, target.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
if viewerID == "" || viewerID == target.ID {
|
||||
return followers, following, nil, nil
|
||||
}
|
||||
rel, err = s.Relationship(ctx, viewerID, target.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
return followers, following, rel, nil
|
||||
}
|
||||
|
||||
// result recomputes the viewer→target relationship and the target's
|
||||
// counts after a mutation.
|
||||
func (s *SocialGraphService) result(ctx context.Context, viewerID string, target *model.User) (*RelationshipResult, error) {
|
||||
rel, err := s.Relationship(ctx, viewerID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
followers, following, err := s.Counts(ctx, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RelationshipResult{
|
||||
Relationship: rel,
|
||||
FollowerCount: followers,
|
||||
FollowingCount: following,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SocialGraphService) notifyAdminsOfReport(reporter, reported *model.User, category, reason string) {
|
||||
if len(s.admins) == 0 {
|
||||
s.logger.Info("user report received but no admin recipients configured",
|
||||
zap.String("reported", reported.ID))
|
||||
return
|
||||
}
|
||||
from := "anonymous"
|
||||
if reporter != nil {
|
||||
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := userReportEmailTpl.Execute(&buf, map[string]any{
|
||||
"ReporterFrom": from,
|
||||
"ReportedName": reported.Name,
|
||||
"ReportedHandle": reported.Handle,
|
||||
"Category": category,
|
||||
"Reason": reason,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render user-report email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("[Report · %s] @%s", category, reported.Handle)
|
||||
body := buf.String()
|
||||
for _, to := range s.admins {
|
||||
if err := s.mail.Send(to, subject, body); err != nil {
|
||||
s.logger.Warn("user-report email send failed", zap.String("to", to), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var userReportEmailTpl = template.Must(template.New("userReport").Parse(`<p><strong>A user has been reported</strong></p>
|
||||
<p><strong>Reported:</strong> {{.ReportedName}} (@{{.ReportedHandle}})<br>
|
||||
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
|
||||
<strong>Category:</strong> {{.Category}}</p>
|
||||
<p><strong>Reason:</strong></p>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>`))
|
||||
|
||||
func (s *SocialGraphService) notifyAdminsOfPostReport(reporter *model.User, post *model.Post, category, reason string) {
|
||||
if len(s.admins) == 0 {
|
||||
s.logger.Info("post report received but no admin recipients configured",
|
||||
zap.String("post", post.ID))
|
||||
return
|
||||
}
|
||||
from := "anonymous"
|
||||
if reporter != nil {
|
||||
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
|
||||
}
|
||||
author := post.AuthorName
|
||||
if post.AuthorHandle != "" {
|
||||
author = fmt.Sprintf("%s (@%s)", post.AuthorName, post.AuthorHandle)
|
||||
}
|
||||
if author == "" {
|
||||
author = "unknown"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := postReportEmailTpl.Execute(&buf, map[string]any{
|
||||
"PostID": post.ID,
|
||||
"Author": author,
|
||||
"ReporterFrom": from,
|
||||
"Category": category,
|
||||
"Reason": reason,
|
||||
"Caption": post.Caption,
|
||||
"SourceURL": post.SourceURL,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render post-report email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("[Post report · %s] %s", category, summarize(post.Caption, 50))
|
||||
body := buf.String()
|
||||
for _, to := range s.admins {
|
||||
if err := s.mail.Send(to, subject, body); err != nil {
|
||||
s.logger.Warn("post-report email send failed", zap.String("to", to), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var postReportEmailTpl = template.Must(template.New("postReport").Parse(`<p><strong>A post has been reported</strong></p>
|
||||
<p><strong>Post ID:</strong> {{.PostID}}<br>
|
||||
<strong>Author:</strong> {{.Author}}<br>
|
||||
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
|
||||
<strong>Category:</strong> {{.Category}}</p>
|
||||
{{if .Reason}}<p><strong>Reason:</strong></p>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>{{end}}
|
||||
<p><strong>Caption:</strong></p>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Caption}}</pre>
|
||||
<p style="color:#999;font-size:12px"><a href="{{.SourceURL}}">{{.SourceURL}}</a></p>`))
|
||||
@@ -35,28 +35,127 @@ 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).
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS notifications;
|
||||
DROP TABLE IF EXISTS user_reports;
|
||||
DROP TABLE IF EXISTS user_blocks;
|
||||
DROP TABLE IF EXISTS follows;
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Social graph: follows, blocks, reports, plus a generic notifications
|
||||
-- table that backs the "notification plugin" (email today; websocket +
|
||||
-- push will read the same rows later).
|
||||
--
|
||||
-- Follows are REQUEST-BASED. Clicking "Follow" inserts a row with
|
||||
-- status='pending'; the target later accepts (status→'accepted',
|
||||
-- responded_at stamped) or rejects (row deleted). A user is "following"
|
||||
-- another only once the edge is 'accepted'. The UNIQUE(follower,
|
||||
-- following) pair makes a repeated request idempotent, and the CHECK
|
||||
-- stops anyone following themselves.
|
||||
CREATE TABLE IF NOT EXISTS follows (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending | accepted
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
responded_at TIMESTAMPTZ, -- set when accepted
|
||||
UNIQUE (follower_id, following_id),
|
||||
CHECK (follower_id <> following_id)
|
||||
);
|
||||
|
||||
-- "Who follows me / who has requested me" — the incoming side. Filtered
|
||||
-- by status so accepted-follower counts and pending-request lists are
|
||||
-- both index-served.
|
||||
CREATE INDEX IF NOT EXISTS follows_following_status_idx
|
||||
ON follows (following_id, status);
|
||||
|
||||
-- "Who I follow / who I've requested" — the outgoing side.
|
||||
CREATE INDEX IF NOT EXISTS follows_follower_status_idx
|
||||
ON follows (follower_id, status);
|
||||
|
||||
-- Blocks. blocker_id has blocked blocked_id. One row per direction; the
|
||||
-- service treats a block in EITHER direction as "these two can't follow
|
||||
-- each other" and tears down any existing follow edges both ways.
|
||||
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (blocker_id, blocked_id),
|
||||
CHECK (blocker_id <> blocked_id)
|
||||
);
|
||||
|
||||
-- Reverse lookup: "has anyone blocked me?" — used to compute blocked_by
|
||||
-- when rendering a profile.
|
||||
CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx
|
||||
ON user_blocks (blocked_id);
|
||||
|
||||
-- Reports. A reporter flags another user with a category + free-text
|
||||
-- reason. resolved_at is one-way (admins clear it later); the partial
|
||||
-- index keeps the open-reports queue cheap to list.
|
||||
CREATE TABLE IF NOT EXISTS user_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reported_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
CHECK (reporter_id <> reported_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_reports_reported_idx
|
||||
ON user_reports (reported_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_reports_unresolved_idx
|
||||
ON user_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||
|
||||
-- Notifications back the in-app/email/push fan-out. Today the
|
||||
-- NotificationService persists a row here AND sends an email; websocket
|
||||
-- + push delivery will later read unread rows for a user. actor_id is
|
||||
-- the user who triggered the event (nullable for system notifications);
|
||||
-- kind is a short machine string ('follow_request', 'follow_accepted').
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- recipient
|
||||
actor_id UUID REFERENCES users(id) ON DELETE CASCADE, -- who caused it
|
||||
kind VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
read_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- "My notifications, newest first" + an unread filter for badge counts.
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_created_idx
|
||||
ON notifications (user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
|
||||
ON notifications (user_id) WHERE read_at IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS country;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Account country (ISO-3166-1 alpha-2), shown in the profile "About this
|
||||
-- account" panel. Captured best-effort at signup (Cloudflare's
|
||||
-- CF-IPCountry header when present, else a GeoIP lookup on the client IP)
|
||||
-- and editable by the user from the profile edit form. NULL until known.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS country CHAR(2);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS gender,
|
||||
DROP COLUMN IF EXISTS initiated,
|
||||
DROP COLUMN IF EXISTS initiated_at,
|
||||
DROP COLUMN IF EXISTS daily_chanting_rounds,
|
||||
DROP COLUMN IF EXISTS preferred_chanting_time,
|
||||
DROP COLUMN IF EXISTS peak_active_time;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Devotional profile details, plus two system-managed fields reserved for
|
||||
-- the (not-yet-built) automated-notification scheduler.
|
||||
--
|
||||
-- gender / initiated / initiated_at / daily_chanting_rounds are
|
||||
-- user-editable from the profile form. preferred_chanting_time and
|
||||
-- peak_active_time are written silently by the backend later (no UI yet) —
|
||||
-- both store a local clock time as "HH:MM" text so the scheduler can pick
|
||||
-- a send window without timezone math at write time.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS gender VARCHAR(16),
|
||||
ADD COLUMN IF NOT EXISTS initiated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS initiated_at DATE,
|
||||
ADD COLUMN IF NOT EXISTS daily_chanting_rounds INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS preferred_chanting_time VARCHAR(5),
|
||||
ADD COLUMN IF NOT EXISTS peak_active_time VARCHAR(5);
|
||||
|
||||
-- Guard the chanting-round count to the meaningful range (1 round up to a
|
||||
-- full lakh of names = 1008). NULL stays allowed (not specified).
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_daily_chanting_rounds_range
|
||||
CHECK (daily_chanting_rounds IS NULL
|
||||
OR (daily_chanting_rounds BETWEEN 1 AND 1008));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS sampradaya;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Devotee's sampradāya (spiritual lineage). Every account defaults to the
|
||||
-- Gaudiya sampradāya; the column is NOT NULL so the value is always
|
||||
-- present. Stored as a lowercase code; the frontend maps it to a display
|
||||
-- label. Existing rows are backfilled to 'gaudiya' by the DEFAULT.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS sampradaya VARCHAR(32) NOT NULL DEFAULT 'gaudiya';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Re-imposing NOT NULL requires backfilling any NULLs first.
|
||||
UPDATE users SET sampradaya = 'gaudiya' WHERE sampradaya IS NULL;
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN sampradaya SET NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Sampradāya is optional. Keep the 'gaudiya' DEFAULT so new accounts still
|
||||
-- start in the Gaudiya lineage, but allow NULL so a devotee can clear it.
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN sampradaya DROP NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS post_reports;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Post reports — a viewer flags a specific post (not its author) with a
|
||||
-- category + optional free-text reason. Mirrors user_reports but keyed to
|
||||
-- a post; resolved_at is one-way (admins clear it later). The partial
|
||||
-- index keeps the open-reports queue cheap to list.
|
||||
CREATE TABLE IF NOT EXISTS post_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_reports_post_idx
|
||||
ON post_reports (post_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_reports_unresolved_idx
|
||||
ON post_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package geoip resolves a request to an ISO-3166-1 alpha-2 country code,
|
||||
// best-effort. Two sources, in priority order:
|
||||
//
|
||||
// 1. Cloudflare's CF-IPCountry header — instant, no network, present when
|
||||
// the site is proxied through Cloudflare.
|
||||
// 2. A GeoIP lookup on the client IP via the free ip-api.com endpoint —
|
||||
// the fallback for when we're not behind Cloudflare (the current
|
||||
// deployment is DNS-only).
|
||||
//
|
||||
// Everything is fire-and-forget: every failure path returns "" and the
|
||||
// caller simply leaves the country unknown. Nothing here blocks a request
|
||||
// on the critical path — callers run LookupCountryByIP from a goroutine.
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CountryFromCloudflare reads the CF-IPCountry header. Cloudflare sets it
|
||||
// to a 2-letter code, or to a sentinel ("XX" unknown, "T1" Tor) we treat
|
||||
// as absent. Returns "" when the header isn't a usable country.
|
||||
func CountryFromCloudflare(r *http.Request) string {
|
||||
cc := strings.ToUpper(strings.TrimSpace(r.Header.Get("CF-IPCountry")))
|
||||
if len(cc) != 2 || cc == "XX" || cc == "T1" {
|
||||
return ""
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// ClientIP returns the best guess at the caller's IP. The chi RealIP
|
||||
// middleware already rewrites r.RemoteAddr from X-Forwarded-For /
|
||||
// X-Real-IP, so we just strip the port.
|
||||
func ClientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err != nil {
|
||||
// RemoteAddr may already be bare (no port) behind some proxies.
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// LookupCountryByIP resolves an IP to a country code via ip-api.com.
|
||||
// Returns "" for empty / private / loopback IPs (no point asking) and on
|
||||
// any error, timeout, or non-success response. Respects ctx for
|
||||
// cancellation/timeout — callers should pass a short-timeout context.
|
||||
func LookupCountryByIP(ctx context.Context, ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() ||
|
||||
parsed.IsLinkLocalUnicast() || parsed.IsUnspecified() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Free tier is HTTP-only; countryCode keeps the payload tiny.
|
||||
url := "http://ip-api.com/json/" + ip + "?fields=status,countryCode"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return ""
|
||||
}
|
||||
if body.Status != "success" {
|
||||
return ""
|
||||
}
|
||||
cc := strings.ToUpper(strings.TrimSpace(body.CountryCode))
|
||||
if len(cc) != 2 {
|
||||
return ""
|
||||
}
|
||||
return cc
|
||||
}
|
||||
Reference in New Issue
Block a user