diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 7b5af28..ed04762 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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, diff --git a/internal/handler/notification.go b/internal/handler/notification.go new file mode 100644 index 0000000..2c0a6a7 --- /dev/null +++ b/internal/handler/notification.go @@ -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) +} diff --git a/internal/handler/relationship.go b/internal/handler/relationship.go new file mode 100644 index 0000000..5c01c36 --- /dev/null +++ b/internal/handler/relationship.go @@ -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) +} diff --git a/internal/handler/social.go b/internal/handler/social.go index 2bb52cb..cd482f6 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -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: diff --git a/internal/handler/user.go b/internal/handler/user.go index 57f3734..a98382f 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -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") diff --git a/internal/model/social.go b/internal/model/social.go index c60e101..cd26640 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -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"` +} diff --git a/internal/model/user.go b/internal/model/user.go index 5b5cf5a..6c0f8f0 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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:"-"` diff --git a/internal/repository/social_graph_repo.go b/internal/repository/social_graph_repo.go new file mode 100644 index 0000000..58a07a2 --- /dev/null +++ b/internal/repository/social_graph_repo.go @@ -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 +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index ed73fc2..1a20e09 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -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) diff --git a/internal/server/server.go b/internal/server/server.go index 263e5fd..375dd27 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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,9 +663,33 @@ func (s *Server) setupRouter() { r.HandleFunc("/*", s.authUnavailable) return } - r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) - r.Get("/{handle}", s.profileH.Get) - r.Get("/{handle}/feed", s.profileH.Feed) + // Public reads (anonymous-OK; OptionalAuth populates viewer_id + // so Get can include the viewer's relationship + the feed can + // carry viewer_has_reshared). + r.Group(func(r chi.Router) { + r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo)) + r.Get("/{handle}", s.profileH.Get) + r.Get("/{handle}/feed", s.profileH.Feed) + }) + + // Relationship actions — follow/block/report. All require a + // signed-in viewer; each returns the recomputed relationship + // + the target's follower/following counts. Follows are + // request-based: POST /follow creates a PENDING request that + // the target later accepts or rejects. + if s.relationH != nil { + r.Group(func(r chi.Router) { + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Post("/{handle}/follow", s.relationH.Follow) + r.Delete("/{handle}/follow", s.relationH.Unfollow) + r.Post("/{handle}/follow/accept", s.relationH.AcceptFollow) + r.Post("/{handle}/follow/reject", s.relationH.RejectFollow) + r.Delete("/{handle}/follower", s.relationH.RemoveFollower) + r.Post("/{handle}/block", s.relationH.Block) + r.Delete("/{handle}/block", s.relationH.Unblock) + r.Post("/{handle}/report", s.relationH.Report) + }) + } }) // URL Shortener — Postgres-backed, Redis-queued. // The handler short-circuits to 503 if Postgres or Redis are unavailable. @@ -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) }) }) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 055c7c1..fe37034 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -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. diff --git a/internal/service/feedback_service.go b/internal/service/feedback_service.go index 56cd473..b5199bf 100644 --- a/internal/service/feedback_service.go +++ b/internal/service/feedback_service.go @@ -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(`
Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏
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(`
Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏
Thank you for the feedback you shared with us:
diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go new file mode 100644 index 0000000..fdaab1c --- /dev/null +++ b/internal/service/notification_service.go @@ -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(`Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏
+ +{{.ActorName}} (@{{.ActorHandle}}) + has requested to follow you on Prabhupāda Says.
+ +You can review and accept or decline the request from their profile:
+ ++ View request +
+ +Your servants,
+ The Prabhupāda Says team
Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏
+ +{{.ActorName}} (@{{.ActorHandle}}) + has accepted your follow request. You're now following them on Prabhupāda Says.
+ ++ Visit profile +
+ +Your servants,
+ The Prabhupāda Says team
A user has been reported
+Reported: {{.ReportedName}} (@{{.ReportedHandle}})
+Reported by: {{.ReporterFrom}}
+Category: {{.Category}}
Reason:
+{{.Reason}}`))
+
+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(`A post has been reported
+Post ID: {{.PostID}}
+Author: {{.Author}}
+Reported by: {{.ReporterFrom}}
+Category: {{.Category}}
Reason:
+{{.Reason}}{{end}}
+Caption:
+{{.Caption}}
+`))
diff --git a/internal/service/user_service.go b/internal/service/user_service.go
index 1bd0077..323ef3d 100644
--- a/internal/service/user_service.go
+++ b/internal/service/user_service.go
@@ -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).
diff --git a/migrations/0019_create_social_graph.down.sql b/migrations/0019_create_social_graph.down.sql
new file mode 100644
index 0000000..4bc5aa4
--- /dev/null
+++ b/migrations/0019_create_social_graph.down.sql
@@ -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;
diff --git a/migrations/0019_create_social_graph.up.sql b/migrations/0019_create_social_graph.up.sql
new file mode 100644
index 0000000..377ef2d
--- /dev/null
+++ b/migrations/0019_create_social_graph.up.sql
@@ -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;
diff --git a/migrations/0020_add_user_country.down.sql b/migrations/0020_add_user_country.down.sql
new file mode 100644
index 0000000..3bc10ab
--- /dev/null
+++ b/migrations/0020_add_user_country.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ DROP COLUMN IF EXISTS country;
diff --git a/migrations/0020_add_user_country.up.sql b/migrations/0020_add_user_country.up.sql
new file mode 100644
index 0000000..04ddfaa
--- /dev/null
+++ b/migrations/0020_add_user_country.up.sql
@@ -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);
diff --git a/migrations/0021_add_user_devotional_fields.down.sql b/migrations/0021_add_user_devotional_fields.down.sql
new file mode 100644
index 0000000..3341f98
--- /dev/null
+++ b/migrations/0021_add_user_devotional_fields.down.sql
@@ -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;
diff --git a/migrations/0021_add_user_devotional_fields.up.sql b/migrations/0021_add_user_devotional_fields.up.sql
new file mode 100644
index 0000000..d5058b7
--- /dev/null
+++ b/migrations/0021_add_user_devotional_fields.up.sql
@@ -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));
diff --git a/migrations/0022_add_user_sampradaya.down.sql b/migrations/0022_add_user_sampradaya.down.sql
new file mode 100644
index 0000000..64cfcc8
--- /dev/null
+++ b/migrations/0022_add_user_sampradaya.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE users
+ DROP COLUMN IF EXISTS sampradaya;
diff --git a/migrations/0022_add_user_sampradaya.up.sql b/migrations/0022_add_user_sampradaya.up.sql
new file mode 100644
index 0000000..55aa55b
--- /dev/null
+++ b/migrations/0022_add_user_sampradaya.up.sql
@@ -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';
diff --git a/migrations/0023_sampradaya_nullable.down.sql b/migrations/0023_sampradaya_nullable.down.sql
new file mode 100644
index 0000000..3e1953f
--- /dev/null
+++ b/migrations/0023_sampradaya_nullable.down.sql
@@ -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;
diff --git a/migrations/0023_sampradaya_nullable.up.sql b/migrations/0023_sampradaya_nullable.up.sql
new file mode 100644
index 0000000..08b770c
--- /dev/null
+++ b/migrations/0023_sampradaya_nullable.up.sql
@@ -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;
diff --git a/migrations/0024_create_post_reports.down.sql b/migrations/0024_create_post_reports.down.sql
new file mode 100644
index 0000000..f1d6df4
--- /dev/null
+++ b/migrations/0024_create_post_reports.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS post_reports;
diff --git a/migrations/0024_create_post_reports.up.sql b/migrations/0024_create_post_reports.up.sql
new file mode 100644
index 0000000..280fde2
--- /dev/null
+++ b/migrations/0024_create_post_reports.up.sql
@@ -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;
diff --git a/pkg/geoip/geoip.go b/pkg/geoip/geoip.go
new file mode 100644
index 0000000..00261b1
--- /dev/null
+++ b/pkg/geoip/geoip.go
@@ -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
+}