feat: social graph features

This commit is contained in:
Krispire Admin
2026-06-04 19:29:48 +05:30
parent d9678ebb3b
commit efa1eaf23b
28 changed files with 1882 additions and 78 deletions
+30
View File
@@ -1,13 +1,16 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/geoip"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
@@ -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,
+51
View File
@@ -0,0 +1,51 @@
package handler
import (
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// NotificationHandler serves the signed-in user's notification feed +
// unread badge count, and marks them read. All routes sit behind
// RequireAuth, so middleware.UserID is always populated.
type NotificationHandler struct{ svc *service.NotificationService }
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
return &NotificationHandler{svc: svc}
}
// List returns the caller's notifications, newest first.
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
items, err := h.svc.List(r.Context(), uid, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notifications")
return
}
response.JSON(w, http.StatusOK, items)
}
// UnreadCount returns {"count": N} for the bell badge.
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
n, err := h.svc.UnreadCount(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load unread count")
return
}
response.JSON(w, http.StatusOK, map[string]int{"count": n})
}
// MarkAllRead marks every notification read (called when the page opens).
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.MarkAllRead(r.Context(), uid); err != nil {
response.Error(w, http.StatusInternalServerError, "could not mark notifications read")
return
}
response.NoContent(w)
}
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// RelationshipHandler exposes the follow / block / report actions, all
// scoped to a target user identified by {handle} in the path and a
// signed-in viewer pulled from the auth middleware. Every mutating
// endpoint returns the recomputed RelationshipResult (viewer's state +
// the target's live counts) so the frontend updates its buttons and
// stat line from the single response — except Report, which returns 204.
//
// These routes live under /api/v1/u/{handle}/… in a RequireAuth group;
// the public profile GET stays anonymous-friendly in its own group.
type RelationshipHandler struct{ svc *service.SocialGraphService }
func NewRelationshipHandler(svc *service.SocialGraphService) *RelationshipHandler {
return &RelationshipHandler{svc: svc}
}
// Follow sends a follow REQUEST (pending until the target accepts).
func (h *RelationshipHandler) Follow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RequestFollow)
}
// Unfollow removes the viewer's edge — stops following or cancels a
// still-pending request.
func (h *RelationshipHandler) Unfollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unfollow)
}
// AcceptFollow accepts the target's pending request to follow the viewer.
func (h *RelationshipHandler) AcceptFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.AcceptFollow)
}
// RejectFollow declines the target's pending request.
func (h *RelationshipHandler) RejectFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RejectFollow)
}
// RemoveFollower drops the target from the viewer's followers.
func (h *RelationshipHandler) RemoveFollower(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RemoveFollower)
}
// Block blocks the target (and severs follows both ways).
func (h *RelationshipHandler) Block(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Block)
}
// Unblock removes the viewer's block on the target.
func (h *RelationshipHandler) Unblock(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unblock)
}
type reportReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against the target. Body: {category, reason}.
func (h *RelationshipHandler) Report(w http.ResponseWriter, r *http.Request) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
var req reportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.Report(r.Context(), viewerID, handle, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// act is the shared shim for every RelationshipResult-returning action:
// pull viewer + handle, run the service method, map errors, return JSON.
func (h *RelationshipHandler) act(
w http.ResponseWriter,
r *http.Request,
fn func(ctx context.Context, viewerID, handle string) (*service.RelationshipResult, error),
) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
res, err := fn(r.Context(), viewerID, handle)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
+49 -4
View File
@@ -26,9 +26,36 @@ import (
// PostHandler
// ---------------------------------------------------------------------------
type PostHandler struct{ svc *service.PostService }
type PostHandler struct {
svc *service.PostService
graph *service.SocialGraphService // post reporting
}
func NewPostHandler(svc *service.PostService) *PostHandler { return &PostHandler{svc: svc} }
func NewPostHandler(svc *service.PostService, graph *service.SocialGraphService) *PostHandler {
return &PostHandler{svc: svc, graph: graph}
}
type reportPostReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against this post. Body: {category, reason?}.
// Auth-required; the reporter is the signed-in viewer.
func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req reportPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.graph.ReportPost(r.Context(), uid, id, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
type createPostReq struct {
Caption string `json:"caption"`
@@ -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:
+25 -2
View File
@@ -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")
+85
View File
@@ -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"`
}
+29
View File
@@ -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:"-"`
+308
View File
@@ -0,0 +1,308 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// social_graph_repo.go bundles the four small repos backing the social
// graph — follows, blocks, reports, and notifications. Each is a thin
// wrapper over the shared Postgres pool, mirroring the per-domain repo
// convention used elsewhere (UserRepo, PostRepo, …). They're grouped in
// one file because each is only a handful of queries and they share the
// same dependency.
// ---------------------------------------------------------------------------
// FollowRepo
// ---------------------------------------------------------------------------
// FollowRepo manages the request-based follows edge table.
type FollowRepo struct{ pg *Postgres }
func NewFollowRepo(pg *Postgres) *FollowRepo { return &FollowRepo{pg: pg} }
// Request inserts a pending follow edge follower→following. Idempotent:
// a repeated request (the edge already exists, pending or accepted) is a
// no-op. Returns created=true only when a brand-new pending row was
// inserted, so the caller fires the "X requested to follow you"
// notification exactly once.
func (r *FollowRepo) Request(ctx context.Context, followerID, followingID string) (created bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO follows (follower_id, following_id, status)
VALUES ($1, $2, 'pending')
ON CONFLICT (follower_id, following_id) DO NOTHING`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("request follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Accept flips a pending edge follower→following to accepted and stamps
// responded_at. Returns accepted=true only on the transition from
// pending (so the "X accepted your request" notification fires once); a
// call on an already-accepted or absent edge returns false.
func (r *FollowRepo) Accept(ctx context.Context, followerID, followingID string) (accepted bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE follows
SET status = 'accepted', responded_at = now()
WHERE follower_id = $1 AND following_id = $2 AND status = 'pending'`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("accept follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Delete removes the edge follower→following regardless of status. Backs
// unfollow (accepted), cancel-request (own pending outgoing), reject
// (their pending incoming), and remove-follower (their accepted edge to
// me). Idempotent.
func (r *FollowRepo) Delete(ctx context.Context, followerID, followingID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
)
if err != nil {
return fmt.Errorf("delete follow: %w", err)
}
return nil
}
// Status returns the status of the edge follower→following ('pending' or
// 'accepted'), or "" when no edge exists.
func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) (string, error) {
var status string
err := r.pg.Pool.QueryRow(ctx, `
SELECT status FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
).Scan(&status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("follow status: %w", err)
}
return status, nil
}
// CountFollowers counts accepted edges pointing at userID.
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE following_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count followers: %w", err)
}
return n, nil
}
// CountFollowing counts accepted edges originating from userID.
func (r *FollowRepo) CountFollowing(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE follower_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count following: %w", err)
}
return n, nil
}
// ---------------------------------------------------------------------------
// BlockRepo
// ---------------------------------------------------------------------------
// BlockRepo manages user_blocks. Block() also tears down any follow
// edges in either direction, so it runs in a transaction.
type BlockRepo struct{ pg *Postgres }
func NewBlockRepo(pg *Postgres) *BlockRepo { return &BlockRepo{pg: pg} }
// Block records blocker→blocked (idempotent) and, in the same tx, deletes
// any follow edges between the two in either direction — blocking someone
// should sever the relationship both ways.
func (r *BlockRepo) Block(ctx context.Context, blockerID, blockedID string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin block tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO user_blocks (blocker_id, blocked_id)
VALUES ($1, $2)
ON CONFLICT (blocker_id, blocked_id) DO NOTHING`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("insert block: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM follows
WHERE (follower_id = $1 AND following_id = $2)
OR (follower_id = $2 AND following_id = $1)`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("sever follows on block: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit block: %w", err)
}
return nil
}
// Unblock removes the blocker→blocked record. Idempotent.
func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2`,
blockerID, blockedID,
)
if err != nil {
return fmt.Errorf("unblock: %w", err)
}
return nil
}
// BlockState reports, in one round-trip, whether a blocked b and whether
// b blocked a.
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
err = r.pg.Pool.QueryRow(ctx, `
SELECT
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2),
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $2 AND blocked_id = $1)`,
a, b,
).Scan(&aBlockedB, &bBlockedA)
if err != nil {
return false, false, fmt.Errorf("block state: %w", err)
}
return aBlockedB, bBlockedA, nil
}
// ---------------------------------------------------------------------------
// ReportRepo
// ---------------------------------------------------------------------------
// ReportRepo manages user_reports.
type ReportRepo struct{ pg *Postgres }
func NewReportRepo(pg *Postgres) *ReportRepo { return &ReportRepo{pg: pg} }
// Create inserts a user-report row.
func (r *ReportRepo) Create(ctx context.Context, reporterID, reportedID, category, reason string) (*model.UserReport, error) {
rep := &model.UserReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO user_reports (reporter_id, reported_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, reported_id, category, reason, created_at, resolved_at`,
reporterID, reportedID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.ReportedID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create report: %w", err)
}
return rep, nil
}
// CreatePostReport inserts a post-report row.
func (r *ReportRepo) CreatePostReport(ctx context.Context, reporterID, postID, category, reason string) (*model.PostReport, error) {
rep := &model.PostReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO post_reports (reporter_id, post_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, post_id, category, reason, created_at, resolved_at`,
reporterID, postID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.PostID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create post report: %w", err)
}
return rep, nil
}
// ---------------------------------------------------------------------------
// NotificationRepo
// ---------------------------------------------------------------------------
// NotificationRepo persists notification rows. Reads (list / unread
// count / mark-read) will be added when the in-app notification center
// ships; for now the table is write-only, feeding the email fan-out and
// standing ready for websocket/push.
type NotificationRepo struct{ pg *Postgres }
func NewNotificationRepo(pg *Postgres) *NotificationRepo { return &NotificationRepo{pg: pg} }
// Create inserts a notification for recipient userID, triggered by
// actorID (nil for system notifications), of the given kind.
func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *string, kind string) (*model.Notification, error) {
n := &model.Notification{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind)
VALUES ($1, $2, $3)
RETURNING id, user_id, actor_id, kind, created_at, read_at`,
userID, actorID, kind,
).Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt)
if err != nil {
return nil, fmt.Errorf("create notification: %w", err)
}
return n, nil
}
// ListByUser returns a recipient's notifications, newest first, with the
// actor's display fields joined in for rendering (nil for system
// notifications or since-deleted actors).
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT n.id, n.user_id, n.actor_id, n.kind, n.created_at, n.read_at,
u.name, u.handle, u.avatar_url
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = $1
ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
out := []model.Notification{}
for rows.Next() {
var n model.Notification
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt,
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
// CountUnread returns how many of a recipient's notifications are unread.
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
}
return n, nil
}
// MarkAllRead stamps read_at on every still-unread notification for the
// user. Idempotent.
func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID string) error {
_, err := r.pg.Pool.Exec(ctx,
`UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL`, userID,
)
if err != nil {
return fmt.Errorf("mark notifications read: %w", err)
}
return nil
}
+141 -55
View File
@@ -29,21 +29,62 @@ func NewUserRepo(pg *Postgres) *UserRepo {
return &UserRepo{pg: pg}
}
// userColumns is the canonical column list for SELECT/RETURNING on the
// users table, in the exact order scanUser reads them. Keeping the two in
// lockstep here is what makes adding a column a single-edit change instead
// of a hunt across every query.
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at, country,
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
preferred_chanting_time, peak_active_time`
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
// scanUser works for single-row and iterated reads alike.
type rowScanner interface {
Scan(dest ...any) error
}
// scanUser reads one users row (selected via userColumns) into u.
func scanUser(row rowScanner, u *model.User) error {
return row.Scan(
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
&u.PreferredChantingTime, &u.PeakActiveTime,
)
}
// ProfileUpdate is the set of fields UpdateProfile can change. Every field
// is a pointer so "leave unchanged" (nil) is distinct from "set". For the
// nullable string fields, a non-nil pointer to "" means "clear to NULL";
// DailyChantingRounds uses *0 as its clear sentinel (1 is the real min).
type ProfileUpdate struct {
Name *string
AvatarURL *string
Handle *string
Country *string
Sampradaya *string // nil=leave, ""=clear, code=set
Gender *string
Initiated *bool
InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear
DailyChantingRounds *int // 0 to clear
PreferredChantingTime *string
PeakActiveTime *string
}
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
// unique-violation. The caller is responsible for picking a unique handle —
// the auth service does this with a retry loop.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
const q = `
q := `
INSERT INTO users (email, password_hash, name, handle)
VALUES ($1, $2, $3, $4)
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
RETURNING ` + userColumns
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q,
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
), u)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
@@ -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)
+54 -4
View File
@@ -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)
})
})
+13
View File
@@ -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.
+2 -2
View File
@@ -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>
+189
View File
@@ -0,0 +1,189 @@
// Package service — NotificationService is the single seam every
// user-facing event flows through ("the notification plugin"). Today it
// does two things per event:
//
// 1. Persists a row in `notifications` (so an in-app notification center
// and, later, websocket + push delivery can read the same history).
// 2. Sends an email to the recipient via the shared mailer.
//
// Everything here is fire-and-forget from the caller's perspective: a
// follow request must succeed even if the email bounces or the insert
// fails, so Notify* never returns an error — failures are logged. When
// websocket/push land they hook in right here alongside the email send.
package service
import (
"bytes"
"context"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
// Notification kinds — short machine strings stored in notifications.kind.
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
)
type NotificationService struct {
notifs *repository.NotificationRepo
users *repository.UserRepo
mail mailer.Mailer
logger *zap.Logger
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
users: users,
mail: mail,
logger: logger,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
}
// List returns the caller's notifications, newest first.
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
if offset < 0 {
offset = 0
}
return s.notifs.ListByUser(ctx, userID, limit, offset)
}
// UnreadCount returns the caller's unread notification count (for the bell
// badge).
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
return s.notifs.CountUnread(ctx, userID)
}
// MarkAllRead marks all of the caller's notifications read.
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
return s.notifs.MarkAllRead(ctx, userID)
}
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
// them. Persists the row and emails the recipient (if they have an email
// on file).
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
"%s requested to follow you — Prabhupāda Says")
}
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
// follow request (i.e. the recipient can now see actor's activity).
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
"%s accepted your follow request — Prabhupāda Says")
}
// dispatch persists the notification and sends the matching email. All
// failures are logged, never propagated.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
actor := actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
// Persistence failed, but still try to email — the email is the
// only delivery channel that exists today.
}
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
return
}
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// No email on file (OAuth-only account that never added one) —
// the persisted row is the notification; nothing to send.
return
}
actorUser, err := s.users.FindByID(ctx, actorID)
if err != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(err))
return
}
body, err := s.render(kind, recipient, actorUser)
if err != nil {
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
return
}
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
s.logger.Warn("notification email send failed",
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
"ActorName": actorDisplay(actor),
"ActorHandle": actor.Handle,
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
}
var tpl *template.Template
switch kind {
case NotifFollowRequest:
tpl = followRequestEmailTpl
case NotifFollowAccepted:
tpl = followAcceptedEmailTpl
default:
tpl = followRequestEmailTpl
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// actorDisplay prefers the display name, falling back to @handle.
func actorDisplay(u *model.User) string {
if n := strings.TrimSpace(u.Name); n != "" {
return n
}
return "@" + u.Handle
}
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
// the actor and link to their profile (where the recipient can accept a
// pending request).
var followRequestEmailTpl = template.Must(template.New("followRequest").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has requested to follow you on Prabhupāda Says.</p>
<p>You can review and accept or decline the request from their profile:</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+435
View File
@@ -0,0 +1,435 @@
// Package service — SocialGraphService is the follow / block / report
// brain. It sits over FollowRepo + BlockRepo + ReportRepo, resolves the
// target by handle, enforces the rules (no self-follow, no following
// across a block), keeps the request-based follow lifecycle honest, and
// routes every notable event through the NotificationService.
//
// Follows are request-based end to end: RequestFollow only ever creates a
// PENDING edge; the target turns it into a real follow via AcceptFollow.
// Every mutating method returns the freshly-recomputed RelationshipResult
// (the viewer's state toward the target + the target's follower/following
// counts) so the frontend can update its buttons and stat line from one
// response.
package service
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
// Sentinels specific to the social graph (ErrForbidden / ErrNotFound are
// shared from social_service.go in this package).
var (
// ErrBlocked — the action can't proceed because one of the two users
// has blocked the other.
ErrBlocked = errors.New("blocked")
// ErrSelfRelation — you can't follow / block / report yourself.
ErrSelfRelation = errors.New("cannot perform this action on yourself")
)
// reportCategories is the allow-list the Report flow validates against —
// mirrored by the frontend's report dialog dropdown.
var reportCategories = map[string]bool{
"spam": true,
"harassment": true,
"impersonation": true,
"inappropriate": true,
"other": true,
}
// postReportCategories is the allow-list for reporting a post — mirrored
// by the frontend's post report dialog.
var postReportCategories = map[string]bool{
"spam": true,
"harassment": true,
"misinformation": true,
"inappropriate": true,
"other": true,
}
// RelationshipResult is the payload every relationship mutation returns:
// the viewer's relationship to the target plus the target's live counts.
type RelationshipResult struct {
Relationship *model.RelationshipState `json:"relationship"`
FollowerCount int `json:"follower_count"`
FollowingCount int `json:"following_count"`
}
type SocialGraphService struct {
follows *repository.FollowRepo
blocks *repository.BlockRepo
reports *repository.ReportRepo
posts *repository.PostRepo
users *repository.UserRepo
notif *NotificationService
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewSocialGraphService(
follows *repository.FollowRepo,
blocks *repository.BlockRepo,
reports *repository.ReportRepo,
posts *repository.PostRepo,
users *repository.UserRepo,
notif *NotificationService,
mail mailer.Mailer,
admins []string,
logger *zap.Logger,
) *SocialGraphService {
return &SocialGraphService{
follows: follows, blocks: blocks, reports: reports, posts: posts,
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
}
}
// resolve looks up the target by handle and rejects self-targeting.
func (s *SocialGraphService) resolve(ctx context.Context, viewerID, handle string) (*model.User, error) {
t, err := s.users.FindByHandle(ctx, handle)
if err != nil {
return nil, err // ErrUserNotFound → 404 via mapSocialErr
}
if t.ID == viewerID {
return nil, ErrSelfRelation
}
return t, nil
}
// RequestFollow inserts a pending follow edge viewer→target and notifies
// the target. Refuses if either party has blocked the other. Idempotent:
// re-requesting an existing edge doesn't re-notify.
func (s *SocialGraphService) RequestFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
viewerBlocked, targetBlocked, err := s.blocks.BlockState(ctx, viewerID, t.ID)
if err != nil {
return nil, err
}
if viewerBlocked || targetBlocked {
return nil, ErrBlocked
}
created, err := s.follows.Request(ctx, viewerID, t.ID)
if err != nil {
return nil, err
}
if created {
s.notif.NotifyFollowRequest(ctx, t.ID, viewerID)
}
return s.result(ctx, viewerID, t)
}
// Unfollow removes the viewer→target edge — covers both "stop following"
// (accepted) and "cancel my pending request". Idempotent.
func (s *SocialGraphService) Unfollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// AcceptFollow accepts the target's pending request to follow the viewer
// (edge target→viewer). On the pending→accepted transition the target is
// notified that their request went through. `handle` is the requester.
func (s *SocialGraphService) AcceptFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
accepted, err := s.follows.Accept(ctx, t.ID, viewerID)
if err != nil {
return nil, err
}
if accepted {
s.notif.NotifyFollowAccepted(ctx, t.ID, viewerID)
}
return s.result(ctx, viewerID, t)
}
// RejectFollow declines the target's pending request to follow the viewer
// by deleting the target→viewer edge. Idempotent; no notification (a
// silent decline is kinder than an explicit rejection email).
func (s *SocialGraphService) RejectFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// RemoveFollower drops the target from the viewer's followers by deleting
// the target→viewer edge. Mechanically the same as RejectFollow but
// distinct in intent (an accepted follower is being removed). Idempotent.
func (s *SocialGraphService) RemoveFollower(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Block records viewer→target and severs any follow edges both ways.
func (s *SocialGraphService) Block(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.blocks.Block(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Unblock removes the viewer→target block.
func (s *SocialGraphService) Unblock(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return nil, err
}
if err := s.blocks.Unblock(ctx, viewerID, t.ID); err != nil {
return nil, err
}
return s.result(ctx, viewerID, t)
}
// Report files a report against the target and emails the admins. The
// category must be in the allow-list and the reason non-empty.
func (s *SocialGraphService) Report(ctx context.Context, viewerID, handle, category, reason string) error {
t, err := s.resolve(ctx, viewerID, handle)
if err != nil {
return err
}
category = strings.ToLower(strings.TrimSpace(category))
if !reportCategories[category] {
return errors.New("invalid report category")
}
reason = strings.TrimSpace(reason)
if reason == "" {
return errors.New("reason is required")
}
if len(reason) > 2000 {
return errors.New("reason too long (max 2000)")
}
if _, err := s.reports.Create(ctx, viewerID, t.ID, category, reason); err != nil {
return err
}
// Email the admins — fire-and-forget; the row is already persisted.
reporter, rerr := s.users.FindByID(ctx, viewerID)
if rerr != nil {
s.logger.Warn("report saved but reporter lookup failed; emailing without reporter info",
zap.String("reporter", viewerID), zap.Error(rerr))
}
s.notifyAdminsOfReport(reporter, t, category, reason)
return nil
}
// ReportPost files a report against a specific post and emails the admins.
// The category must be in the post allow-list; the reason is optional.
func (s *SocialGraphService) ReportPost(ctx context.Context, reporterID, postID, category, reason string) error {
category = strings.ToLower(strings.TrimSpace(category))
if !postReportCategories[category] {
return errors.New("invalid report category")
}
reason = strings.TrimSpace(reason)
if len(reason) > 2000 {
return errors.New("reason too long (max 2000)")
}
post, err := s.posts.FindByID(ctx, postID)
if err != nil {
return err // ErrPostNotFound → 404 via mapSocialErr
}
if _, err := s.reports.CreatePostReport(ctx, reporterID, post.ID, category, reason); err != nil {
return err
}
reporter, rerr := s.users.FindByID(ctx, reporterID)
if rerr != nil {
s.logger.Warn("post report saved but reporter lookup failed; emailing without reporter info",
zap.String("reporter", reporterID), zap.Error(rerr))
}
s.notifyAdminsOfPostReport(reporter, post, category, reason)
return nil
}
// Relationship computes the viewer's relationship toward targetID.
func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetID string) (*model.RelationshipState, error) {
out := &model.RelationshipState{}
outStatus, err := s.follows.Status(ctx, viewerID, targetID) // viewer → target
if err != nil {
return nil, err
}
inStatus, err := s.follows.Status(ctx, targetID, viewerID) // target → viewer
if err != nil {
return nil, err
}
out.Following = outStatus == "accepted"
out.FollowRequested = outStatus == "pending"
out.FollowsYou = inStatus == "accepted"
out.RequestsYou = inStatus == "pending"
viewerBlocked, blockedByTarget, err := s.blocks.BlockState(ctx, viewerID, targetID)
if err != nil {
return nil, err
}
out.Blocked = viewerBlocked
out.BlockedBy = blockedByTarget
return out, nil
}
// Counts returns the accepted-follower and accepted-following totals for
// a user.
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
followers, err = s.follows.CountFollowers(ctx, userID)
if err != nil {
return 0, 0, err
}
following, err = s.follows.CountFollowing(ctx, userID)
if err != nil {
return 0, 0, err
}
return followers, following, nil
}
// ProfileExtras is the read used by the public-profile endpoint: the
// target's follower/following counts plus, when a non-owner viewer is
// signed in, their relationship to the target. rel is nil for anonymous
// viewers and for the owner viewing their own profile.
func (s *SocialGraphService) ProfileExtras(ctx context.Context, viewerID string, target *model.User) (followers, following int, rel *model.RelationshipState, err error) {
followers, following, err = s.Counts(ctx, target.ID)
if err != nil {
return 0, 0, nil, err
}
if viewerID == "" || viewerID == target.ID {
return followers, following, nil, nil
}
rel, err = s.Relationship(ctx, viewerID, target.ID)
if err != nil {
return 0, 0, nil, err
}
return followers, following, rel, nil
}
// result recomputes the viewer→target relationship and the target's
// counts after a mutation.
func (s *SocialGraphService) result(ctx context.Context, viewerID string, target *model.User) (*RelationshipResult, error) {
rel, err := s.Relationship(ctx, viewerID, target.ID)
if err != nil {
return nil, err
}
followers, following, err := s.Counts(ctx, target.ID)
if err != nil {
return nil, err
}
return &RelationshipResult{
Relationship: rel,
FollowerCount: followers,
FollowingCount: following,
}, nil
}
func (s *SocialGraphService) notifyAdminsOfReport(reporter, reported *model.User, category, reason string) {
if len(s.admins) == 0 {
s.logger.Info("user report received but no admin recipients configured",
zap.String("reported", reported.ID))
return
}
from := "anonymous"
if reporter != nil {
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
}
var buf bytes.Buffer
if err := userReportEmailTpl.Execute(&buf, map[string]any{
"ReporterFrom": from,
"ReportedName": reported.Name,
"ReportedHandle": reported.Handle,
"Category": category,
"Reason": reason,
}); err != nil {
s.logger.Warn("render user-report email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Report · %s] @%s", category, reported.Handle)
body := buf.String()
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("user-report email send failed", zap.String("to", to), zap.Error(err))
}
}
}
var userReportEmailTpl = template.Must(template.New("userReport").Parse(`<p><strong>A user has been reported</strong></p>
<p><strong>Reported:</strong> {{.ReportedName}} (@{{.ReportedHandle}})<br>
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
<strong>Category:</strong> {{.Category}}</p>
<p><strong>Reason:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>`))
func (s *SocialGraphService) notifyAdminsOfPostReport(reporter *model.User, post *model.Post, category, reason string) {
if len(s.admins) == 0 {
s.logger.Info("post report received but no admin recipients configured",
zap.String("post", post.ID))
return
}
from := "anonymous"
if reporter != nil {
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
}
author := post.AuthorName
if post.AuthorHandle != "" {
author = fmt.Sprintf("%s (@%s)", post.AuthorName, post.AuthorHandle)
}
if author == "" {
author = "unknown"
}
var buf bytes.Buffer
if err := postReportEmailTpl.Execute(&buf, map[string]any{
"PostID": post.ID,
"Author": author,
"ReporterFrom": from,
"Category": category,
"Reason": reason,
"Caption": post.Caption,
"SourceURL": post.SourceURL,
}); err != nil {
s.logger.Warn("render post-report email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Post report · %s] %s", category, summarize(post.Caption, 50))
body := buf.String()
for _, to := range s.admins {
if err := s.mail.Send(to, subject, body); err != nil {
s.logger.Warn("post-report email send failed", zap.String("to", to), zap.Error(err))
}
}
}
var postReportEmailTpl = template.Must(template.New("postReport").Parse(`<p><strong>A post has been reported</strong></p>
<p><strong>Post ID:</strong> {{.PostID}}<br>
<strong>Author:</strong> {{.Author}}<br>
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
<strong>Category:</strong> {{.Category}}</p>
{{if .Reason}}<p><strong>Reason:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>{{end}}
<p><strong>Caption:</strong></p>
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Caption}}</pre>
<p style="color:#999;font-size:12px"><a href="{{.SourceURL}}">{{.SourceURL}}</a></p>`))
+107 -8
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
-- Account country (ISO-3166-1 alpha-2), shown in the profile "About this
-- account" panel. Captured best-effort at signup (Cloudflare's
-- CF-IPCountry header when present, else a GeoIP lookup on the client IP)
-- and editable by the user from the profile edit form. NULL until known.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS country CHAR(2);
@@ -0,0 +1,8 @@
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
ALTER TABLE users
DROP COLUMN IF EXISTS gender,
DROP COLUMN IF EXISTS initiated,
DROP COLUMN IF EXISTS initiated_at,
DROP COLUMN IF EXISTS daily_chanting_rounds,
DROP COLUMN IF EXISTS preferred_chanting_time,
DROP COLUMN IF EXISTS peak_active_time;
@@ -0,0 +1,24 @@
-- Devotional profile details, plus two system-managed fields reserved for
-- the (not-yet-built) automated-notification scheduler.
--
-- gender / initiated / initiated_at / daily_chanting_rounds are
-- user-editable from the profile form. preferred_chanting_time and
-- peak_active_time are written silently by the backend later (no UI yet) —
-- both store a local clock time as "HH:MM" text so the scheduler can pick
-- a send window without timezone math at write time.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS gender VARCHAR(16),
ADD COLUMN IF NOT EXISTS initiated BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS initiated_at DATE,
ADD COLUMN IF NOT EXISTS daily_chanting_rounds INTEGER,
ADD COLUMN IF NOT EXISTS preferred_chanting_time VARCHAR(5),
ADD COLUMN IF NOT EXISTS peak_active_time VARCHAR(5);
-- Guard the chanting-round count to the meaningful range (1 round up to a
-- full lakh of names = 1008). NULL stays allowed (not specified).
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
ALTER TABLE users
ADD CONSTRAINT users_daily_chanting_rounds_range
CHECK (daily_chanting_rounds IS NULL
OR (daily_chanting_rounds BETWEEN 1 AND 1008));
@@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN IF EXISTS sampradaya;
@@ -0,0 +1,6 @@
-- Devotee's sampradāya (spiritual lineage). Every account defaults to the
-- Gaudiya sampradāya; the column is NOT NULL so the value is always
-- present. Stored as a lowercase code; the frontend maps it to a display
-- label. Existing rows are backfilled to 'gaudiya' by the DEFAULT.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS sampradaya VARCHAR(32) NOT NULL DEFAULT 'gaudiya';
@@ -0,0 +1,4 @@
-- Re-imposing NOT NULL requires backfilling any NULLs first.
UPDATE users SET sampradaya = 'gaudiya' WHERE sampradaya IS NULL;
ALTER TABLE users
ALTER COLUMN sampradaya SET NOT NULL;
@@ -0,0 +1,4 @@
-- Sampradāya is optional. Keep the 'gaudiya' DEFAULT so new accounts still
-- start in the Gaudiya lineage, but allow NULL so a devotee can clear it.
ALTER TABLE users
ALTER COLUMN sampradaya DROP NOT NULL;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS post_reports;
@@ -0,0 +1,19 @@
-- Post reports — a viewer flags a specific post (not its author) with a
-- category + optional free-text reason. Mirrors user_reports but keyed to
-- a post; resolved_at is one-way (admins clear it later). The partial
-- index keeps the open-reports queue cheap to list.
CREATE TABLE IF NOT EXISTS post_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
category VARCHAR(32) NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS post_reports_post_idx
ON post_reports (post_id, created_at DESC);
CREATE INDEX IF NOT EXISTS post_reports_unresolved_idx
ON post_reports (created_at DESC) WHERE resolved_at IS NULL;
+92
View File
@@ -0,0 +1,92 @@
// Package geoip resolves a request to an ISO-3166-1 alpha-2 country code,
// best-effort. Two sources, in priority order:
//
// 1. Cloudflare's CF-IPCountry header — instant, no network, present when
// the site is proxied through Cloudflare.
// 2. A GeoIP lookup on the client IP via the free ip-api.com endpoint —
// the fallback for when we're not behind Cloudflare (the current
// deployment is DNS-only).
//
// Everything is fire-and-forget: every failure path returns "" and the
// caller simply leaves the country unknown. Nothing here blocks a request
// on the critical path — callers run LookupCountryByIP from a goroutine.
package geoip
import (
"context"
"encoding/json"
"net"
"net/http"
"strings"
"time"
)
// CountryFromCloudflare reads the CF-IPCountry header. Cloudflare sets it
// to a 2-letter code, or to a sentinel ("XX" unknown, "T1" Tor) we treat
// as absent. Returns "" when the header isn't a usable country.
func CountryFromCloudflare(r *http.Request) string {
cc := strings.ToUpper(strings.TrimSpace(r.Header.Get("CF-IPCountry")))
if len(cc) != 2 || cc == "XX" || cc == "T1" {
return ""
}
return cc
}
// ClientIP returns the best guess at the caller's IP. The chi RealIP
// middleware already rewrites r.RemoteAddr from X-Forwarded-For /
// X-Real-IP, so we just strip the port.
func ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
// RemoteAddr may already be bare (no port) behind some proxies.
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
// LookupCountryByIP resolves an IP to a country code via ip-api.com.
// Returns "" for empty / private / loopback IPs (no point asking) and on
// any error, timeout, or non-success response. Respects ctx for
// cancellation/timeout — callers should pass a short-timeout context.
func LookupCountryByIP(ctx context.Context, ip string) string {
ip = strings.TrimSpace(ip)
if ip == "" {
return ""
}
parsed := net.ParseIP(ip)
if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() ||
parsed.IsLinkLocalUnicast() || parsed.IsUnspecified() {
return ""
}
// Free tier is HTTP-only; countryCode keeps the payload tiny.
url := "http://ip-api.com/json/" + ip + "?fields=status,countryCode"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return ""
}
client := &http.Client{Timeout: 4 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var body struct {
Status string `json:"status"`
CountryCode string `json:"countryCode"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return ""
}
if body.Status != "success" {
return ""
}
cc := strings.ToUpper(strings.TrimSpace(body.CountryCode))
if len(cc) != 2 {
return ""
}
return cc
}