feat: post reshare enhancements
This commit is contained in:
@@ -59,18 +59,19 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusCreated, p)
|
response.JSON(w, http.StatusCreated, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reshare records the caller as having reshared the post identified
|
// Reshare materialises a reshare of the post identified by {id} as a
|
||||||
// by {id}. Idempotent. Returns 204 on success; conflict for the
|
// new posts row owned by the caller. Idempotent — second call returns
|
||||||
// "reshare your own post" case surfaces as 400 via mapSocialErr's
|
// the same row (still 201). The body is the new (or existing) post so
|
||||||
// fallthrough (errors.New wrapper in the service).
|
// the frontend can drop it into its local feed without a refetch.
|
||||||
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
|
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := middleware.UserID(r.Context())
|
uid := middleware.UserID(r.Context())
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if err := h.svc.Reshare(r.Context(), id, uid); err != nil {
|
p, err := h.svc.Reshare(r.Context(), id, uid)
|
||||||
|
if err != nil {
|
||||||
mapSocialErr(w, err)
|
mapSocialErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
response.JSON(w, http.StatusCreated, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unreshare removes a previously recorded reshare. Idempotent.
|
// Unreshare removes a previously recorded reshare. Idempotent.
|
||||||
|
|||||||
+17
-11
@@ -5,6 +5,11 @@ import "time"
|
|||||||
// Post is a public quote on a user's feed. Caption is the verbatim selected
|
// Post is a public quote on a user's feed. Caption is the verbatim selected
|
||||||
// text from the knowledgebase; SourceURL links back to the page the user
|
// text from the knowledgebase; SourceURL links back to the page the user
|
||||||
// was reading. Images are loaded separately via PostImage rows.
|
// was reading. Images are loaded separately via PostImage rows.
|
||||||
|
//
|
||||||
|
// Reshares are real Post rows owned by the resharer, with
|
||||||
|
// ResharedFromPostID pointing to the canonical original. Likes, comments,
|
||||||
|
// and images all hang off the post's own id, so they diverge naturally
|
||||||
|
// across reshare boundaries — no parallel "per-resharer" tables.
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
@@ -20,19 +25,20 @@ type Post struct {
|
|||||||
AuthorHandle string `json:"author_handle,omitempty"`
|
AuthorHandle string `json:"author_handle,omitempty"`
|
||||||
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||||
|
|
||||||
// ViewerHasReshared is true when the calling user has reshared this
|
// Reshare attribution. ResharedFromPostID is non-nil when this post
|
||||||
// post. Hydrated by the service layer after the repo returns posts;
|
// is a reshare; the four `Original*` fields carry the canonical
|
||||||
// always false for anonymous viewers (no viewer = no reshare state).
|
// post's author info so the frontend can render a
|
||||||
|
// "Reshared from @handle" link without an extra round-trip.
|
||||||
|
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
|
||||||
|
OriginalAuthorName *string `json:"original_author_name,omitempty"`
|
||||||
|
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
|
||||||
|
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
|
||||||
|
|
||||||
|
// ViewerHasReshared is true when the calling user has reshared the
|
||||||
|
// canonical version of this post. Hydrated by the service layer
|
||||||
|
// after the repo returns posts; always false for anonymous viewers.
|
||||||
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
|
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
|
||||||
ViewerHasReshared bool `json:"viewer_has_reshared"`
|
ViewerHasReshared bool `json:"viewer_has_reshared"`
|
||||||
|
|
||||||
// ResharedAt is the timestamp at which the feed owner reshared this
|
|
||||||
// post. Non-nil only for rows that appear in a profile feed because
|
|
||||||
// the profile owner reshared them (i.e. the row comes from the
|
|
||||||
// post_reshares union branch of ListByUser / ListByHandle).
|
|
||||||
// Authored-by-feed-owner rows leave this nil. Drives the
|
|
||||||
// "↻ Reshared" badge in the post card.
|
|
||||||
ResharedAt *time.Time `json:"reshared_at,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostImage represents one attached image on a post.
|
// PostImage represents one attached image on a post.
|
||||||
|
|||||||
+220
-141
@@ -13,10 +13,53 @@ import (
|
|||||||
var ErrPostNotFound = errors.New("post not found")
|
var ErrPostNotFound = errors.New("post not found")
|
||||||
|
|
||||||
// PostRepo wraps Postgres access for posts + post_images.
|
// PostRepo wraps Postgres access for posts + post_images.
|
||||||
|
//
|
||||||
|
// Reshare model: a reshare is a real `posts` row owned by the resharer
|
||||||
|
// with `reshared_from_post_id` pointing at the canonical original. Likes
|
||||||
|
// and comments (future) will hang off post.id, so they diverge between
|
||||||
|
// original and reshare for free. Backgrounds are per-row in post_images,
|
||||||
|
// same story. The only thing the original and its reshares share is the
|
||||||
|
// caption / source_url / source_title at the moment of reshare.
|
||||||
type PostRepo struct{ pg *Postgres }
|
type PostRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
|
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
|
||||||
|
|
||||||
|
// postSelectColumns is the canonical column list used by every reader so
|
||||||
|
// every Post payload carries the same shape — including reshare
|
||||||
|
// attribution. The LEFT JOINs to `original_post` + `original_user` are
|
||||||
|
// always present; for canonical (non-reshare) rows they just yield NULL.
|
||||||
|
const postSelectColumns = `
|
||||||
|
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,
|
||||||
|
p.reshared_from_post_id,
|
||||||
|
ou.name AS original_author_name,
|
||||||
|
ou.handle AS original_author_handle,
|
||||||
|
ou.avatar_url AS original_author_avatar
|
||||||
|
`
|
||||||
|
|
||||||
|
const postSelectFrom = `
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
|
||||||
|
LEFT JOIN users ou ON ou.id = op.user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
// scanPost decodes one row produced by `postSelectColumns` into the
|
||||||
|
// destination Post. Single source of truth for the column → struct
|
||||||
|
// mapping, used by every reader.
|
||||||
|
func scanPost(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}, p *model.Post) error {
|
||||||
|
return row.Scan(
|
||||||
|
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||||
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||||
|
&p.ResharedFromPostID,
|
||||||
|
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Create inserts a post + its images in a single transaction so we never
|
// Create inserts a post + its images in a single transaction so we never
|
||||||
// end up with orphan images.
|
// end up with orphan images.
|
||||||
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
||||||
@@ -57,12 +100,9 @@ func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourc
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceImages atomically swaps a post's image set. Ownership is
|
// ReplaceImages atomically swaps a post's image set. Owner-only — every
|
||||||
// enforced inside the tx by locking the post row and checking user_id —
|
// reshare is its own posts row now, so the resharer is the owner of
|
||||||
// non-owners get ErrPostNotFound (we don't distinguish "not yours" from
|
// their reshare and edits it just like any other post they made.
|
||||||
// "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 {
|
func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
|
||||||
tx, err := r.pg.Pool.Begin(ctx)
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,21 +138,12 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
|
|||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByID returns one post with its images and author info.
|
// FindByID returns one post with its images and author info (plus original
|
||||||
|
// author info if this row is a reshare).
|
||||||
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
||||||
const q = `
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
|
||||||
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{}
|
p := &model.Post{}
|
||||||
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(
|
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
||||||
&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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
}
|
}
|
||||||
@@ -126,12 +157,8 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByHandle returns the profile feed for a user — authored posts
|
// ListByHandle returns the profile feed for a user. Unknown handle →
|
||||||
// plus posts the user reshared — newest-first, paginated. Resolves
|
// empty page (matches pre-existing behaviour).
|
||||||
// handle → user_id and then delegates to ListByUser so both code paths
|
|
||||||
// produce the same shape (and so the union query lives in one place).
|
|
||||||
// Unknown handles return an empty page (not an error) — that matches
|
|
||||||
// the pre-reshare behaviour.
|
|
||||||
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||||
var userID string
|
var userID string
|
||||||
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
|
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
|
||||||
@@ -143,56 +170,26 @@ func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offse
|
|||||||
return r.ListByUser(ctx, userID, limit, offset)
|
return r.ListByUser(ctx, userID, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByUser returns the user's profile feed — authored posts plus
|
// ListByUser returns the user's profile feed — every post they own,
|
||||||
// posts they reshared. Each row carries a reshared_at timestamp that
|
// authored or reshared. Reshares are interleaved naturally because
|
||||||
// is NULL for authored rows and equal to post_reshares.created_at for
|
// they're stored as real rows; sort by created_at gets the right order
|
||||||
// reshared rows; rows are sorted by COALESCE(reshared_at, created_at)
|
// without any UNION gymnastics.
|
||||||
// so fresh reshares of old posts surface at the top, matching the
|
|
||||||
// Twitter/X model.
|
|
||||||
//
|
|
||||||
// `total` is the union count so pagination spans the combined feed.
|
|
||||||
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
var total int
|
total, err := r.CountByUser(ctx, userID)
|
||||||
if err := r.pg.Pool.QueryRow(ctx, `
|
if err != nil {
|
||||||
SELECT
|
return nil, 0, err
|
||||||
(SELECT COUNT(*) FROM posts WHERE user_id = $1)
|
|
||||||
+ (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`,
|
|
||||||
userID,
|
|
||||||
).Scan(&total); err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("count feed: %w", err)
|
|
||||||
}
|
}
|
||||||
const q = `
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
SELECT id, user_id, caption, source_url, source_title,
|
WHERE p.user_id = $1
|
||||||
created_at, updated_at, author_name, author_handle,
|
ORDER BY p.created_at DESC, p.id
|
||||||
author_avatar_url, reshared_at
|
|
||||||
FROM (
|
|
||||||
SELECT p.id, p.user_id, p.caption, p.source_url,
|
|
||||||
COALESCE(p.source_title, '') AS source_title,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
u.name AS author_name, u.handle AS author_handle,
|
|
||||||
u.avatar_url AS author_avatar_url,
|
|
||||||
NULL::timestamptz AS reshared_at
|
|
||||||
FROM posts p
|
|
||||||
JOIN users u ON u.id = p.user_id
|
|
||||||
WHERE p.user_id = $1
|
|
||||||
UNION ALL
|
|
||||||
SELECT p.id, p.user_id, p.caption, p.source_url,
|
|
||||||
COALESCE(p.source_title, '') AS source_title,
|
|
||||||
p.created_at, p.updated_at,
|
|
||||||
u.name AS author_name, u.handle AS author_handle,
|
|
||||||
u.avatar_url AS author_avatar_url,
|
|
||||||
r.created_at AS reshared_at
|
|
||||||
FROM post_reshares r
|
|
||||||
JOIN posts p ON p.id = r.post_id
|
|
||||||
JOIN users u ON u.id = p.user_id
|
|
||||||
WHERE r.user_id = $1
|
|
||||||
) feed
|
|
||||||
ORDER BY COALESCE(reshared_at, created_at) DESC, id
|
|
||||||
LIMIT $2 OFFSET $3`
|
LIMIT $2 OFFSET $3`
|
||||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a post only if it belongs to the caller.
|
// Delete removes a post only if it belongs to the caller. Cascades:
|
||||||
|
// - the post's images (post_images.post_id FK)
|
||||||
|
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
||||||
|
// - those reshares' images (post_images.post_id FK on each reshare)
|
||||||
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
||||||
tag, err := r.pg.Pool.Exec(ctx,
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
|
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
|
||||||
@@ -205,24 +202,167 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountByUser returns the profile-feed total — authored + reshared —
|
// CountByUser returns the user's total post count (authored + reshared).
|
||||||
// which is what the profile header's "N posts" badge displays. Stays
|
// Now just a single COUNT against posts.user_id.
|
||||||
// in sync with ListByUser's total so the badge never disagrees with
|
|
||||||
// the page contents below it.
|
|
||||||
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.pg.Pool.QueryRow(ctx, `
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
SELECT
|
`SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID,
|
||||||
(SELECT COUNT(*) FROM posts WHERE user_id = $1)
|
|
||||||
+ (SELECT COUNT(*) FROM post_reshares WHERE user_id = $1)`,
|
|
||||||
userID,
|
|
||||||
).Scan(&n)
|
).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count feed: %w", err)
|
return 0, fmt.Errorf("count posts: %w", err)
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reshare creates a new posts row owned by `resharerID` that points at
|
||||||
|
// the canonical original via `reshared_from_post_id`. Copies the
|
||||||
|
// original's caption / source_url / source_title and its current images
|
||||||
|
// so the resharer's view renders identically at the moment of reshare;
|
||||||
|
// after that the two diverge — edits to either side never touch the
|
||||||
|
// other.
|
||||||
|
//
|
||||||
|
// If the post being reshared is itself a reshare we point at *its*
|
||||||
|
// canonical (transitive ancestor), so every reshare always references
|
||||||
|
// the true original.
|
||||||
|
//
|
||||||
|
// Idempotent: a user already resharing a canonical post returns the
|
||||||
|
// existing reshare row instead of creating a second one.
|
||||||
|
//
|
||||||
|
// Service-layer rule (caller can't reshare their own canonical) is
|
||||||
|
// enforced above this method.
|
||||||
|
func (r *PostRepo) Reshare(ctx context.Context, originalPostID, resharerID 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) }()
|
||||||
|
|
||||||
|
// Load the post being reshared. Walk one level back to canonical
|
||||||
|
// in case the caller is resharing someone else's reshare.
|
||||||
|
var (
|
||||||
|
canonicalID string
|
||||||
|
caption string
|
||||||
|
sourceURL string
|
||||||
|
sourceTitle string
|
||||||
|
canonicalUser string
|
||||||
|
)
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.reshared_from_post_id, p.id),
|
||||||
|
p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||||
|
COALESCE(op.user_id, p.user_id)
|
||||||
|
FROM posts p
|
||||||
|
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
|
||||||
|
WHERE p.id = $1`, originalPostID,
|
||||||
|
).Scan(&canonicalID, &caption, &sourceURL, &sourceTitle, &canonicalUser); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrPostNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("load canonical: %w", err)
|
||||||
|
}
|
||||||
|
if canonicalUser == resharerID {
|
||||||
|
return nil, errors.New("cannot reshare your own post")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: existing reshare by this user of this canonical?
|
||||||
|
var existingID string
|
||||||
|
switch err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id FROM posts
|
||||||
|
WHERE user_id = $1 AND reshared_from_post_id = $2
|
||||||
|
LIMIT 1`, resharerID, canonicalID,
|
||||||
|
).Scan(&existingID); {
|
||||||
|
case err == nil:
|
||||||
|
// Already exists — commit the no-op tx and return the existing row.
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return r.FindByID(ctx, existingID)
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
// Fresh reshare path below.
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("check existing reshare: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newID string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO posts (user_id, caption, source_url, source_title, reshared_from_post_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
resharerID, caption, sourceURL, sourceTitle, canonicalID,
|
||||||
|
).Scan(&newID); err != nil {
|
||||||
|
return nil, fmt.Errorf("insert reshare post: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the canonical's current images as the resharer's starting set.
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
SELECT $1, url, position
|
||||||
|
FROM post_images
|
||||||
|
WHERE post_id = $2`, newID, canonicalID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("copy images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return r.FindByID(ctx, newID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreshare removes the caller's reshare of a canonical post. The
|
||||||
|
// caller passes the canonical post id (what the UI knows); we look up
|
||||||
|
// the caller's reshare-row and delete it. Idempotent — no reshare to
|
||||||
|
// delete is a no-op.
|
||||||
|
func (r *PostRepo) Unreshare(ctx context.Context, canonicalPostID, resharerID string) error {
|
||||||
|
if _, err := r.pg.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM posts
|
||||||
|
WHERE user_id = $1 AND reshared_from_post_id = $2`,
|
||||||
|
resharerID, canonicalPostID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("unreshare: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReshareStatesFor returns the set of post IDs from the supplied list
|
||||||
|
// that the viewer has reshared. Used by feed renderers to flip the
|
||||||
|
// post-card "Reshare ↔ Send" toggle.
|
||||||
|
//
|
||||||
|
// `postIDs` may carry either canonical posts or reshare rows; the query
|
||||||
|
// resolves each id to its canonical first, then asks "does the viewer
|
||||||
|
// have a row whose reshared_from_post_id equals that canonical?". The
|
||||||
|
// returned map keys are the supplied ids — callers don't need to know
|
||||||
|
// the canonical relationship.
|
||||||
|
func (r *PostRepo) ReshareStatesFor(ctx context.Context, viewerID string, postIDs []string) (map[string]bool, error) {
|
||||||
|
out := make(map[string]bool, len(postIDs))
|
||||||
|
if viewerID == "" || len(postIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT p.id
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.id = ANY($2)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM posts r
|
||||||
|
WHERE r.user_id = $1
|
||||||
|
AND r.reshared_from_post_id = COALESCE(p.reshared_from_post_id, p.id)
|
||||||
|
)`, viewerID, postIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query reshare states: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan reshare state: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// --- internal helpers ---
|
// --- internal helpers ---
|
||||||
|
|
||||||
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
|
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
|
||||||
@@ -236,12 +376,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
|||||||
ids := []string{}
|
ids := []string{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p model.Post
|
var p model.Post
|
||||||
if err := rows.Scan(
|
if err := scanPost(rows, &p); err != nil {
|
||||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
|
||||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
|
||||||
&p.ResharedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("scan post: %w", err)
|
return nil, 0, fmt.Errorf("scan post: %w", err)
|
||||||
}
|
}
|
||||||
posts = append(posts, p)
|
posts = append(posts, p)
|
||||||
@@ -263,62 +398,6 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
|||||||
return posts, total, nil
|
return posts, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddReshare records that `viewerID` reshared `postID`. Idempotent —
|
|
||||||
// repeated calls with the same pair are no-ops (the PRIMARY KEY makes
|
|
||||||
// the second insert a conflict, which we explicitly ignore). Returns
|
|
||||||
// nil on success regardless of whether a row was actually inserted.
|
|
||||||
func (r *PostRepo) AddReshare(ctx context.Context, postID, viewerID string) error {
|
|
||||||
const q = `
|
|
||||||
INSERT INTO post_reshares (post_id, user_id)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT (post_id, user_id) DO NOTHING`
|
|
||||||
if _, err := r.pg.Pool.Exec(ctx, q, postID, viewerID); err != nil {
|
|
||||||
return fmt.Errorf("add reshare: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveReshare deletes the reshare relationship. Idempotent — deleting
|
|
||||||
// a row that doesn't exist returns nil rather than ErrPostNotFound,
|
|
||||||
// since "I'm not subscribed" is the desired end state either way.
|
|
||||||
func (r *PostRepo) RemoveReshare(ctx context.Context, postID, viewerID string) error {
|
|
||||||
const q = `DELETE FROM post_reshares WHERE post_id = $1 AND user_id = $2`
|
|
||||||
if _, err := r.pg.Pool.Exec(ctx, q, postID, viewerID); err != nil {
|
|
||||||
return fmt.Errorf("remove reshare: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReshareStatesFor returns a set of post IDs that `viewerID` has
|
|
||||||
// reshared, filtered to the supplied `postIDs`. Same shape as
|
|
||||||
// ImagesFor — one batched query so a feed render doesn't N+1 the
|
|
||||||
// reshare table. Returns an empty map when `viewerID` is "" (anonymous
|
|
||||||
// viewer can't have reshared anything).
|
|
||||||
func (r *PostRepo) ReshareStatesFor(ctx context.Context, viewerID string, postIDs []string) (map[string]bool, error) {
|
|
||||||
out := make(map[string]bool, len(postIDs))
|
|
||||||
if viewerID == "" || len(postIDs) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
|
||||||
SELECT post_id
|
|
||||||
FROM post_reshares
|
|
||||||
WHERE user_id = $1 AND post_id = ANY($2)`,
|
|
||||||
viewerID, postIDs,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query reshare states: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var id string
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan reshare state: %w", err)
|
|
||||||
}
|
|
||||||
out[id] = true
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImagesFor returns a map post_id → [images...] for a batch of post IDs in
|
// 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. Exported because the
|
// a single query so we don't N+1 the feed endpoint. Exported because the
|
||||||
// CollectionService needs to hydrate images onto posts that come back
|
// CollectionService needs to hydrate images onto posts that come back
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
|||||||
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
|
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
|
||||||
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
|
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
|
||||||
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks},
|
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks},
|
||||||
{`SELECT COUNT(*) FROM post_reshares`, nil, &s.Reshares},
|
{`SELECT COUNT(*) FROM posts WHERE reshared_from_post_id IS NOT NULL`, nil, &s.Reshares},
|
||||||
{`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback},
|
{`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback},
|
||||||
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
|
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,39 +75,33 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reshare records that the caller has reshared the given post. Idempotent
|
// Reshare materialises a reshare as a brand-new posts row owned by the
|
||||||
// — calling it twice with the same (post, user) is a no-op. Resharing
|
// caller, with reshared_from_post_id pointing to the canonical original.
|
||||||
// your own post is rejected: there's nothing to bring onto an account
|
// Idempotent — calling it twice returns the same reshare row. Resharing
|
||||||
// that already authored the post.
|
// one's own post (canonical or chained) is rejected at the repo layer.
|
||||||
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) error {
|
//
|
||||||
p, err := s.posts.FindByID(ctx, postID)
|
// Returns the new post so the frontend can drop it into local state
|
||||||
if err != nil {
|
// without a round-trip.
|
||||||
return err
|
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) {
|
||||||
}
|
return s.posts.Reshare(ctx, postID, callerID)
|
||||||
if p.UserID == callerID {
|
|
||||||
return errors.New("cannot reshare your own post")
|
|
||||||
}
|
|
||||||
return s.posts.AddReshare(ctx, postID, callerID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unreshare removes a previously recorded reshare. Idempotent — no-ops
|
// Unreshare removes the caller's reshare of a canonical post by deleting
|
||||||
// when no row exists.
|
// their own reshare posts row. Idempotent — no-ops when nothing to delete.
|
||||||
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error {
|
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error {
|
||||||
return s.posts.RemoveReshare(ctx, postID, callerID)
|
return s.posts.Unreshare(ctx, postID, callerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hydrateReshareStates fills in ViewerHasReshared on each post via one
|
// hydrateReshareStates fills in ViewerHasReshared on each post via one
|
||||||
// batched lookup against post_reshares. Skips the query when the viewer
|
// batched lookup. Skips the query when the viewer is anonymous (the
|
||||||
// is anonymous (`viewerID == ""`) since the answer is uniformly false.
|
// answer is uniformly false) or the slice is empty.
|
||||||
// Also fast-paths posts authored by the viewer — they can't be reshared
|
|
||||||
// by themselves, so we leave the flag at its zero value.
|
|
||||||
func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
|
func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
|
||||||
if viewerID == "" || len(posts) == 0 {
|
if viewerID == "" || len(posts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ids := make([]string, 0, len(posts))
|
ids := make([]string, 0, len(posts))
|
||||||
for _, p := range posts {
|
for _, p := range posts {
|
||||||
if p == nil || p.UserID == viewerID {
|
if p == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ids = append(ids, p.ID)
|
ids = append(ids, p.ID)
|
||||||
@@ -150,9 +144,9 @@ func (s *PostService) Delete(ctx context.Context, postID, callerID string) error
|
|||||||
return s.posts.Delete(ctx, postID, callerID)
|
return s.posts.Delete(ctx, postID, callerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceImages swaps the image set on a post. The caller must own the
|
// ReplaceImages swaps the image set on a post. Owner-only — every
|
||||||
// post. Returns the fresh post payload so the client can reconcile its
|
// reshare is its own posts row, owned by the resharer, so this works
|
||||||
// local state without a separate fetch.
|
// uniformly for canonical posts and reshares.
|
||||||
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
|
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
|
||||||
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
|
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -180,12 +174,11 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
|
|||||||
return posts, total, nil
|
return posts, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListMine is the owner's view of their own profile feed — authored
|
// ListMine is the owner's view of their own profile feed. Every post
|
||||||
// posts plus reshares (the repo's ListByUser unions the two). The
|
// they own — authored or reshared — comes from posts.user_id, so the
|
||||||
// viewer here is by construction the feed owner, so every reshared
|
// viewer is by construction the owner. ViewerHasReshared is true for
|
||||||
// row is one the viewer themselves reshared — we flip
|
// reshare rows (the user IS the resharer) and false for canonical
|
||||||
// ViewerHasReshared inline rather than spending a hydration round-trip
|
// rows (you don't "reshare your own post").
|
||||||
// to learn what we already know.
|
|
||||||
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
if limit <= 0 || limit > 50 {
|
if limit <= 0 || limit > 50 {
|
||||||
limit = 20
|
limit = 20
|
||||||
@@ -198,7 +191,7 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
for i := range posts {
|
for i := range posts {
|
||||||
if posts[i].ResharedAt != nil {
|
if posts[i].ResharedFromPostID != nil {
|
||||||
posts[i].ViewerHasReshared = true
|
posts[i].ViewerHasReshared = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS post_reshare_images;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Per-reshare background image overrides.
|
||||||
|
--
|
||||||
|
-- Previously every viewer of a reshare saw the canonical post_images row —
|
||||||
|
-- so when either the original author or any resharer "Updated background"
|
||||||
|
-- the change leaked across all timelines that referenced the post. This
|
||||||
|
-- table separates "the post's background" from "this user's reshare of
|
||||||
|
-- the post's background": authoring stays in post_images, resharing
|
||||||
|
-- stores its own snapshot here.
|
||||||
|
--
|
||||||
|
-- At reshare time we copy the post's current post_images into this table
|
||||||
|
-- so the resharer's feed renders identically to the author's. After that
|
||||||
|
-- moment the two are independent — the resharer's background lives only
|
||||||
|
-- here, the original author's lives only in post_images, and changes to
|
||||||
|
-- one never touch the other.
|
||||||
|
--
|
||||||
|
-- Cascade: the FK targets the (post_id, user_id) primary key on
|
||||||
|
-- post_reshares. Every path that removes a reshare relationship (the
|
||||||
|
-- author deletes the post, the resharer un-reshares, either user is
|
||||||
|
-- deleted) drops the post_reshares row, which cascades the overrides
|
||||||
|
-- away. The user's stated rule — "should not be in sync except deletion"
|
||||||
|
-- — is preserved end-to-end without any extra application code.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_reshare_images (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
post_id UUID NOT NULL,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (post_id, user_id)
|
||||||
|
REFERENCES post_reshares (post_id, user_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_post_reshare_images_lookup
|
||||||
|
ON post_reshare_images (post_id, user_id, position);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Reverting this requires materialising the new reshared posts back into
|
||||||
|
-- the post_reshares + post_reshare_images join model and then dropping
|
||||||
|
-- the column. Documented but not automated — run manually if a rollback
|
||||||
|
-- is ever actually needed.
|
||||||
|
--
|
||||||
|
-- Step 1: recreate the old tables.
|
||||||
|
-- Step 2: insert one row per reshare back into post_reshares.
|
||||||
|
-- Step 3: copy each reshare's post_images back into post_reshare_images.
|
||||||
|
-- Step 4: delete the materialised reshare rows from `posts`.
|
||||||
|
-- Step 5: drop the column.
|
||||||
|
|
||||||
|
ALTER TABLE posts DROP COLUMN IF EXISTS reshared_from_post_id;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- Reshares become real `posts` rows.
|
||||||
|
--
|
||||||
|
-- Previously a reshare was a join-table relationship between (post, user)
|
||||||
|
-- that pointed at the original author's canonical row. Likes, comments,
|
||||||
|
-- and (most recently) background overrides all had to be plumbed through
|
||||||
|
-- a parallel "per-resharer" path to avoid leaking across timelines. The
|
||||||
|
-- next set of social features (likes, comments) would multiply that
|
||||||
|
-- complexity again.
|
||||||
|
--
|
||||||
|
-- This migration collapses that model: every reshare is its own row in
|
||||||
|
-- `posts`, owned by the resharer, with a `reshared_from_post_id` pointer
|
||||||
|
-- back to the canonical original. Likes and comments will naturally hang
|
||||||
|
-- off post.id and diverge for free; backgrounds are already per-row in
|
||||||
|
-- post_images so the per-reshare override table goes away.
|
||||||
|
--
|
||||||
|
-- Deletion still cascades: the FK uses ON DELETE CASCADE on
|
||||||
|
-- reshared_from_post_id, so an author deleting their post nukes every
|
||||||
|
-- reshare of it (including their post_images via the existing FK).
|
||||||
|
|
||||||
|
-- 1. New self-referential column. Nullable: NULL on canonical posts,
|
||||||
|
-- set on reshares.
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS reshared_from_post_id UUID
|
||||||
|
REFERENCES posts(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_reshared_from
|
||||||
|
ON posts (reshared_from_post_id)
|
||||||
|
WHERE reshared_from_post_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Composite index used by ReshareStatesFor — given a viewer + a batch of
|
||||||
|
-- canonical post ids, "which of these has the viewer reshared?".
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_user_reshared_from
|
||||||
|
ON posts (user_id, reshared_from_post_id)
|
||||||
|
WHERE reshared_from_post_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Backfill: each row in post_reshares becomes a real `posts` row,
|
||||||
|
-- copying the canonical post's textual fields and the timestamp the
|
||||||
|
-- reshare originally happened. The new row is owned by the resharer.
|
||||||
|
--
|
||||||
|
-- Skipped if post_reshares doesn't exist (clean install) — the
|
||||||
|
-- DO block uses to_regclass so a fresh database doesn't error.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.post_reshares') IS NOT NULL THEN
|
||||||
|
INSERT INTO posts (
|
||||||
|
id, user_id, caption, source_url, source_title,
|
||||||
|
created_at, updated_at, reshared_from_post_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
r.user_id,
|
||||||
|
p.caption,
|
||||||
|
p.source_url,
|
||||||
|
p.source_title,
|
||||||
|
r.created_at,
|
||||||
|
r.created_at,
|
||||||
|
p.id
|
||||||
|
FROM post_reshares r
|
||||||
|
JOIN posts p ON p.id = r.post_id;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- 3. Carry the per-resharer image overrides over. Each override row maps
|
||||||
|
-- to the newly-inserted post via (reshared_from_post_id, user_id).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.post_reshare_images') IS NOT NULL THEN
|
||||||
|
INSERT INTO post_images (post_id, url, position)
|
||||||
|
SELECT np.id, ri.url, ri.position
|
||||||
|
FROM post_reshare_images ri
|
||||||
|
JOIN posts np
|
||||||
|
ON np.reshared_from_post_id = ri.post_id
|
||||||
|
AND np.user_id = ri.user_id;
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
-- 4. Old tables are no longer referenced anywhere — drop them so the
|
||||||
|
-- schema mirrors the new model.
|
||||||
|
DROP TABLE IF EXISTS post_reshare_images;
|
||||||
|
DROP TABLE IF EXISTS post_reshares;
|
||||||
Reference in New Issue
Block a user