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(`
+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(`

+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

+
`)) + +var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`
+

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

+
`)) diff --git a/internal/service/social_graph_service.go b/internal/service/social_graph_service.go new file mode 100644 index 0000000..04439c3 --- /dev/null +++ b/internal/service/social_graph_service.go @@ -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(`

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}}

+{{if .Reason}}

Reason:

+
{{.Reason}}
{{end}} +

Caption:

+
{{.Caption}}
+

{{.SourceURL}}

`)) 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 +}