feat: add user centric features

This commit is contained in:
Krispire Admin
2026-05-30 23:43:27 +05:30
parent 97422c1ef5
commit aecdd1030a
21 changed files with 2037 additions and 69 deletions
+96
View File
@@ -0,0 +1,96 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrBookmarkNotFound = errors.New("bookmark not found")
var ErrBookmarkExists = errors.New("bookmark already exists for this URL")
type BookmarkRepo struct{ pg *Postgres }
func NewBookmarkRepo(pg *Postgres) *BookmarkRepo { return &BookmarkRepo{pg: pg} }
// Create inserts a new bookmark. ErrBookmarkExists on (user_id, source_url) collision.
func (r *BookmarkRepo) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
b := &model.Bookmark{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO bookmarks (user_id, source_url, source_title, excerpt, note)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at`,
userID, sourceURL, sourceTitle, excerpt, note,
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "23505") {
return nil, ErrBookmarkExists
}
return nil, fmt.Errorf("create bookmark: %w", err)
}
return b, nil
}
// ListByUser returns all bookmarks for the caller, newest first.
func (r *BookmarkRepo) ListByUser(ctx context.Context, userID string) ([]model.Bookmark, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
FROM bookmarks WHERE user_id = $1 ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("query bookmarks: %w", err)
}
defer rows.Close()
out := []model.Bookmark{}
for rows.Next() {
var b model.Bookmark
if err := rows.Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle,
&b.Excerpt, &b.Note, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("scan bookmark: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// CountByUser is used by the plan-limit enforcer.
func (r *BookmarkRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM bookmarks WHERE user_id = $1`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count bookmarks: %w", err)
}
return n, nil
}
// Delete removes a bookmark belonging to the caller.
func (r *BookmarkRepo) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM bookmarks WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete bookmark: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrBookmarkNotFound
}
return nil
}
// FindByID returns one bookmark (used for the owner-check path).
func (r *BookmarkRepo) FindByID(ctx context.Context, id string) (*model.Bookmark, error) {
b := &model.Bookmark{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
FROM bookmarks WHERE id = $1`, id,
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrBookmarkNotFound
}
return nil, fmt.Errorf("find bookmark: %w", err)
}
return b, nil
}
+263
View File
@@ -0,0 +1,263 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrCollectionNotFound = errors.New("collection not found")
type CollectionRepo struct{ pg *Postgres }
func NewCollectionRepo(pg *Postgres) *CollectionRepo { return &CollectionRepo{pg: pg} }
// Create inserts a new collection owned by userID.
func (r *CollectionRepo) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO collections (user_id, title, description, cover_url, is_public)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`,
userID, title, description, coverURL, isPublic,
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("create collection: %w", err)
}
return c, nil
}
// FindByID returns one collection by id (no owner check — caller decides
// what to do with the visibility flag).
func (r *CollectionRepo) FindByID(ctx context.Context, id string) (*model.CollectionRecord, error) {
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
FROM collections WHERE id = $1`, id,
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCollectionNotFound
}
return nil, fmt.Errorf("find collection: %w", err)
}
return c, nil
}
// ListByUser returns all collections for the caller (own listing).
func (r *CollectionRepo) ListByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return r.list(ctx, `WHERE user_id = $1 ORDER BY updated_at DESC`, userID)
}
// ListPublicByUser returns only the public collections for /u/<handle>.
func (r *CollectionRepo) ListPublicByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return r.list(ctx, `WHERE user_id = $1 AND is_public = TRUE ORDER BY updated_at DESC`, userID)
}
// CountByUserAndVisibility — used by the plan-limit enforcer
// (free: max 5 public, 5 private).
func (r *CollectionRepo) CountByUserAndVisibility(ctx context.Context, userID string, isPublic bool) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM collections WHERE user_id = $1 AND is_public = $2`,
userID, isPublic,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count collections: %w", err)
}
return n, nil
}
// Update modifies title/description/cover/visibility. Nil pointers mean
// "leave unchanged"; isPublic is unconditional (use a pointer if needed).
func (r *CollectionRepo) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
const q = `
UPDATE collections
SET title = COALESCE($3, title),
description = COALESCE($4, description),
cover_url = CASE WHEN $5::boolean THEN $6 ELSE cover_url END,
is_public = COALESCE($7, is_public),
updated_at = now()
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`
updateCover := coverURL != nil
var coverVal *string
if updateCover && *coverURL != "" {
coverVal = coverURL
}
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, q, id, userID, title, description, updateCover, coverVal, isPublic).
Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCollectionNotFound
}
return nil, fmt.Errorf("update collection: %w", err)
}
return c, nil
}
// Delete removes a collection (cascades to collection_items via FK).
func (r *CollectionRepo) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM collections WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete collection: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCollectionNotFound
}
return nil
}
// AddItem inserts a post into a collection. Idempotent — ON CONFLICT.
func (r *CollectionRepo) AddItem(ctx context.Context, collectionID, postID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO collection_items (collection_id, post_id, position)
VALUES ($1, $2, COALESCE(
(SELECT MAX(position) + 1 FROM collection_items WHERE collection_id = $1),
0
))
ON CONFLICT (collection_id, post_id) DO NOTHING`,
collectionID, postID,
)
if err != nil {
return fmt.Errorf("add collection item: %w", err)
}
return nil
}
// SetItemOrder rewrites positions for the given posts so the new order
// matches the input array. Runs in a single transaction so concurrent
// reads see either the old order or the new one — never a half-applied
// state. Posts not in the input list are left untouched (they keep
// whatever position they had); callers should pass the full membership
// when they want a clean ordering.
func (r *CollectionRepo) SetItemOrder(ctx context.Context, collectionID string, postIDs []string) error {
if len(postIDs) == 0 {
return nil
}
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
for i, pid := range postIDs {
if _, err := tx.Exec(ctx, `
UPDATE collection_items SET position = $1
WHERE collection_id = $2 AND post_id = $3`,
i, collectionID, pid,
); err != nil {
return fmt.Errorf("set position %d: %w", i, err)
}
}
return tx.Commit(ctx)
}
// RemoveItem deletes a post from a collection.
func (r *CollectionRepo) RemoveItem(ctx context.Context, collectionID, postID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM collection_items WHERE collection_id = $1 AND post_id = $2`,
collectionID, postID,
)
if err != nil {
return fmt.Errorf("remove collection item: %w", err)
}
return nil
}
// CollectionsContaining returns the ids of collections owned by
// `userID` that already include `postID`. The drawer uses this to grey
// out rows the caller can't add the post to a second time.
func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, userID string) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT ci.collection_id
FROM collection_items ci
JOIN collections c ON c.id = ci.collection_id
WHERE ci.post_id = $1 AND c.user_id = $2`,
postID, userID,
)
if err != nil {
return nil, fmt.Errorf("collections containing post: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan collection id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// PostsIn returns the posts (with images + author) inside a collection.
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM collection_items ci
JOIN posts p ON p.id = ci.post_id
JOIN users u ON u.id = p.user_id
WHERE ci.collection_id = $1
ORDER BY ci.position, ci.added_at`,
collectionID,
)
if err != nil {
return nil, fmt.Errorf("query collection posts: %w", err)
}
defer rows.Close()
posts := []model.Post{}
ids := []string{}
for rows.Next() {
var p model.Post
if err := rows.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
ids = append(ids, p.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate collection posts: %w", err)
}
// Defer image hydration to caller via PostRepo if needed; for v1 we just
// return without images here (the management page can fetch separately).
return posts, nil
}
// --- internal helpers ---
func (r *CollectionRepo) list(ctx context.Context, where string, args ...any) ([]model.CollectionRecord, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
FROM collections `+where, args...,
)
if err != nil {
return nil, fmt.Errorf("query collections: %w", err)
}
defer rows.Close()
out := []model.CollectionRecord{}
for rows.Next() {
var c model.CollectionRecord
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan collection: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
+257
View File
@@ -0,0 +1,257 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrPostNotFound is returned by lookup methods when no row matches.
var ErrPostNotFound = errors.New("post not found")
// PostRepo wraps Postgres access for posts + post_images.
type PostRepo struct{ pg *Postgres }
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
// Create inserts a post + its images in a single transaction so we never
// end up with orphan images.
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
p := &model.Post{}
err = tx.QueryRow(ctx, `
INSERT INTO posts (user_id, caption, source_url, source_title)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, caption, source_url, COALESCE(source_title, ''), created_at, updated_at`,
userID, caption, sourceURL, sourceTitle,
).Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("insert post: %w", err)
}
for i, url := range imageURLs {
var img model.PostImage
err = tx.QueryRow(ctx, `
INSERT INTO post_images (post_id, url, position)
VALUES ($1, $2, $3)
RETURNING id, url, position`,
p.ID, url, i,
).Scan(&img.ID, &img.URL, &img.Position)
if err != nil {
return nil, fmt.Errorf("insert post image %d: %w", i, err)
}
p.Images = append(p.Images, img)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return p, nil
}
// ReplaceImages atomically swaps a post's image set. Ownership is
// enforced inside the tx by locking the post row and checking user_id —
// non-owners get ErrPostNotFound (we don't distinguish "not yours" from
// "doesn't exist" to non-owners). Existing rows are wiped, the new URLs
// are inserted with sequential positions, and the post's `updated_at`
// is touched so list views can sort by recency if needed later.
func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var owner string
if err := tx.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1 FOR UPDATE`, postID).Scan(&owner); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrPostNotFound
}
return fmt.Errorf("lock post: %w", err)
}
if owner != userID {
return ErrPostNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM post_images WHERE post_id = $1`, postID); err != nil {
return fmt.Errorf("delete images: %w", err)
}
for i, url := range imageURLs {
if _, err := tx.Exec(ctx, `
INSERT INTO post_images (post_id, url, position)
VALUES ($1, $2, $3)`, postID, url, i,
); err != nil {
return fmt.Errorf("insert image %d: %w", i, err)
}
}
if _, err := tx.Exec(ctx, `UPDATE posts SET updated_at = now() WHERE id = $1`, postID); err != nil {
return fmt.Errorf("touch post: %w", err)
}
return tx.Commit(ctx)
}
// FindByID returns one post with its images and author info.
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1`
p := &model.Post{}
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPostNotFound
}
return nil, fmt.Errorf("find post: %w", err)
}
imgs, err := r.imagesFor(ctx, []string{p.ID})
if err != nil {
return nil, err
}
p.Images = imgs[p.ID]
return p, nil
}
// ListByHandle returns the public feed for a user, newest-first, paginated.
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var total int
if err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM posts p
JOIN users u ON u.id = p.user_id
WHERE u.handle = $1`, handle,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count posts: %w", err)
}
const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE u.handle = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, handle, limit, offset)
}
// ListByUser returns the user's own posts (private listing — same shape).
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
var total int
if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count posts: %w", err)
}
const q = `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// Delete removes a post only if it belongs to the caller.
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
if err != nil {
return fmt.Errorf("delete post: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrPostNotFound
}
return nil
}
// CountByUser is used by services that need to know how many posts a user
// has — currently the public-profile endpoint only.
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count user posts: %w", err)
}
return n, nil
}
// --- internal helpers ---
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
rows, err := r.pg.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("query posts: %w", err)
}
defer rows.Close()
posts := []model.Post{}
ids := []string{}
for rows.Next() {
var p model.Post
if err := rows.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
); err != nil {
return nil, 0, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
ids = append(ids, p.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate posts: %w", err)
}
if len(ids) > 0 {
imgs, err := r.imagesFor(ctx, ids)
if err != nil {
return nil, 0, err
}
for i := range posts {
posts[i].Images = imgs[posts[i].ID]
}
}
return posts, total, nil
}
// imagesFor returns a map post_id → [images...] for a batch of post IDs in
// a single query so we don't N+1 the feed endpoint.
func (r *PostRepo) imagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
out := make(map[string][]model.PostImage, len(postIDs))
if len(postIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT post_id, id, url, position
FROM post_images
WHERE post_id = ANY($1)
ORDER BY post_id, position`, postIDs,
)
if err != nil {
return nil, fmt.Errorf("query post images: %w", err)
}
defer rows.Close()
for rows.Next() {
var postID string
var img model.PostImage
if err := rows.Scan(&postID, &img.ID, &img.URL, &img.Position); err != nil {
return nil, fmt.Errorf("scan post image: %w", err)
}
out[postID] = append(out[postID], img)
}
return out, rows.Err()
}
+38 -18
View File
@@ -17,6 +17,9 @@ var ErrUserNotFound = errors.New("user not found")
// ErrEmailTaken is returned by Create when the email already exists.
var ErrEmailTaken = errors.New("email already registered")
// ErrHandleTaken is returned when the chosen handle collides with an existing user.
var ErrHandleTaken = errors.New("handle already in use")
// UserRepo provides CRUD on the users table.
type UserRepo struct {
pg *Postgres
@@ -26,21 +29,25 @@ func NewUserRepo(pg *Postgres) *UserRepo {
return &UserRepo{pg: pg}
}
// Create inserts a new user. Returns ErrEmailTaken on unique-violation.
// `email` is lowercased before insert; the column is CITEXT so case doesn't
// matter for lookups, but lower-casing keeps stored values tidy.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name string) (*model.User, error) {
// 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 = `
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, password_hash, name, avatar_url, role,
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`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
err := 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)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
@@ -60,15 +67,21 @@ func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error)
return r.findOne(ctx, `WHERE id = $1`, id)
}
// FindByHandle returns the user with the given handle (case-insensitive
// thanks to the citext column), or ErrUserNotFound.
func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User, error) {
return r.findOne(ctx, `WHERE handle = $1`, strings.TrimSpace(handle))
}
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
q := `
SELECT id, email, password_hash, name, avatar_url, role,
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`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, args...).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -79,16 +92,20 @@ func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*mod
return u, nil
}
// UpdateProfile updates the user's name and/or avatar. Pass nil to leave a field unchanged.
// Returns the updated user.
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
// leave a field unchanged. Returns the updated user.
//
// `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, password_hash, name, avatar_url, role,
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
@@ -101,13 +118,16 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, a
}
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal).
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
return nil, fmt.Errorf("update profile: %w", err)
}
return u, nil