Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id 92E502A7
	for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 21 Apr 2026 12:41:20 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.221.177; helo=mail-vk1-f177.google.com; envelope-from=peter@netlandish.com; receiver=<UNKNOWN> 
Authentication-Results: mail.netlandish.com;
	dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=HzAaE9on
Received: from mail-vk1-f177.google.com (mail-vk1-f177.google.com [209.85.221.177])
	by mail.netlandish.com (Postfix) with ESMTP id 5BDE81D815E
	for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 21 Apr 2026 12:41:17 +0000 (UTC)
Received: by mail-vk1-f177.google.com with SMTP id 71dfb90a1353d-56efdc96b05so2700503e0c.1
        for <~netlandish/links-dev@lists.code.netlandish.com>; Tue, 21 Apr 2026 05:41:17 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1776775277; x=1777380077; darn=lists.code.netlandish.com;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:from:to:cc:subject:date:message-id:reply-to;
        bh=ihT1GTHVOh5fag94UwaK3OGw+Xj+5HluKucL/gMmdyY=;
        b=HzAaE9onWF7xOiXHB58iRQpzZ+tHuGh/tCtqQabB1NZwPa0LENcrde9EZbIBaThLvn
         KKR3IKdZLw7uKYDxIcN37j6sF62cSuRksPSHwr2fCCQakyI+Cr7TV3Xn/2x+1QEry0QH
         ouPhRQNIwjpabTcrRZKkNVLscZPXCE1ICValw=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20251104; t=1776775277; x=1777380077;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:x-gm-gg:x-gm-message-state:from:to:cc:subject:date
         :message-id:reply-to;
        bh=ihT1GTHVOh5fag94UwaK3OGw+Xj+5HluKucL/gMmdyY=;
        b=BBCHHhSUyC7CaxST0NvdBReBUKx9MvFh7pLcZtB9OqdU2k34eDKSXm8BTD4Lc5lf0W
         2g7F22lhzgAWbarjGm0GmuJg9LJenY7W59ERZ+xR7zHY31JM19UsZQMJ8seWvbTRoApL
         t9wbHG2rfzt7Sppd+0y/mm4nSC94z1XMqxnt+G+TOc9qYaBLM7D3QUCY4bCTCaNx9rLD
         L+3cf1x0M8cs1/yzpeYOh0vJyV9Vp6SzcbtRQGHBnGfSNn4LZAX/0GCPH0PGyiLlUMR4
         51PQJeBz0dWE5aza0Eb8CN2hIvW9r/PwUVrDGHlong4zxD1p58I+Syoco/0RWK4vdtKs
         2lug==
X-Gm-Message-State: AOJu0YwwOZk62AUY5pSMM5FPF10GTQTBOEHMjQjs9I7aQ0K7cugsjF2r
	8YtTwEwdDvL1CLFqEpbbwsSxq9U5ebBs4OgVXvo++Yp7T1xuyo6hn8//UAEMAru/I8LzPJEJhZz
	ozNr6dvk=
X-Gm-Gg: AeBDiesC2y2HTBzmuuc3qIZcUEXgJQVzBHA0zba48Zlb0yIOSEtfmQCNfNvmfow6sqM
	uA7aTW1hU1d43hKVDUcBJslTqmvovwZGFpNV+ibLKMZE02H0leWwrYgK8WcSK5foPZAm4KSRvMy
	9WezDrrMfvxH9t1l14F11h2Hq++5aonHiHLpbk9IN01if7wpYHLlTLlA3lLvuf7NgPYthpyJyZW
	u733dLBwPOBcdBe2kOhiwz3VJHCAUjwEsqTgkuJGmCbfWPiWLJ7Rlf/AXgkO+6oK5CckoKoI3iK
	3d80nzTm9xda0OwT1o7MSSIDXDOkn7VezvLEkXBhlY9GVs4rNqupQ9o3ikPLQWr42f5F2XCsFYQ
	JJ1aVLMSS45kqLO1arrJC2fyNj7XuqtwFIBI8j0jDJDC0/ryWeXNc9Vu5Thm5o3rlEYx00zR1XS
	9DXK3PJHFzb0eh4eQvc05GVBWNFXH1PZovwraViTsLTHqbGw==
X-Received: by 2002:a05:6122:e253:b0:56a:fcbf:8aa4 with SMTP id 71dfb90a1353d-56fa5811e9fmr8810225e0c.2.1776775276608;
        Tue, 21 Apr 2026 05:41:16 -0700 (PDT)
Received: from localhost ([2803:2d60:1107:d27:863b:bcb8:bb3b:d9a8])
        by smtp.gmail.com with ESMTPSA id 71dfb90a1353d-56fa93253aesm7663276e0c.14.2026.04.21.05.41.15
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Tue, 21 Apr 2026 05:41:16 -0700 (PDT)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Cc: Peter Sanchez <peter@netlandish.com>
Subject: [PATCH links] bug: when same tag is given twice postgresql errors due to `ON CONFLICT` collision. This dedupes tag processing.
Date: Tue, 21 Apr 2026 06:41:08 -0600
Message-ID: <20260421124112.32167-1-peter@netlandish.com>
X-Mailer: git-send-email 2.52.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 api/api_test.go           | 187 ++++++++++++++++++++++++++++++++++++++
 helpers.go                |  11 ++-
 models/tag_link_shorts.go |   9 +-
 models/tag_links.go       |   9 +-
 4 files changed, 211 insertions(+), 5 deletions(-)

diff --git a/api/api_test.go b/api/api_test.go
index edf5ab6..ec779dd 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -3579,6 +3579,193 @@ func TestAPI(t *testing.T) {
 		c.Equal(0, len(tagLinksAAfter), "Link should have 0 tags after update with empty tags")
 	})
 
+	t.Run("addLink dedupes tags that resolve to the same slug", func(t *testing.T) {
+		c := require.New(t)
+		type AddLinkResponse struct {
+			Link models.OrgLink `json:"addLink"`
+		}
+
+		addLinkQuery := `mutation AddLink($title: String!, $url: String!, $description: String,
+						  $visibility: LinkVisibility!, $unread: Boolean!, $starred: Boolean!,
+						  $archive: Boolean! $slug: String!, $tags: String) {
+					addLink(input: {
+								title: $title,
+								url: $url,
+								description: $description,
+								visibility: $visibility,
+								unread: $unread,
+								starred: $starred,
+								archive: $archive,
+								orgSlug: $slug,
+								tags: $tags}) {
+						id
+						hash
+					}
+				}`
+
+		var link AddLinkResponse
+		op := gqlclient.NewOperation(addLinkQuery)
+		op.Var("title", "Link with duplicate slug tags on create")
+		op.Var("url", "https://example.com/dupe-tags-add")
+		op.Var("description", "")
+		op.Var("visibility", models.OrgLinkVisibilityPrivate)
+		op.Var("tags", "dupe-add, Dupe-Add, DUPE-ADD")
+		op.Var("slug", "personal-org")
+		op.Var("unread", false)
+		op.Var("starred", false)
+		op.Var("archive", false)
+		err := links.Execute(ctx, op, &link)
+		c.NoError(err)
+		c.NotZero(link.Link.ID)
+
+		tagLinks, err := models.GetTagLinks(dbCtx,
+			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", link.Link.ID)})
+		c.NoError(err)
+		c.Equal(1, len(tagLinks), "duplicate-slug tags should collapse to a single tag_links row")
+		c.Equal("dupe-add", tagLinks[0].Name, "first occurrence should win for the stored name")
+	})
+
+	t.Run("updateLink dedupes tags that resolve to the same slug", func(t *testing.T) {
+		c := require.New(t)
+		type AddLinkResponse struct {
+			Link models.OrgLink `json:"addLink"`
+		}
+		type UpdateLinkResponse struct {
+			Link models.OrgLink `json:"updateLink"`
+		}
+
+		addLinkQuery := `mutation AddLink($title: String!, $url: String!, $description: String,
+						  $visibility: LinkVisibility!, $unread: Boolean!, $starred: Boolean!,
+						  $archive: Boolean! $slug: String!, $tags: String) {
+					addLink(input: {
+								title: $title,
+								url: $url,
+								description: $description,
+								visibility: $visibility,
+								unread: $unread,
+								starred: $starred,
+								archive: $archive,
+								orgSlug: $slug,
+								tags: $tags}) {
+						id
+						hash
+					}
+				}`
+
+		var link AddLinkResponse
+		opAdd := gqlclient.NewOperation(addLinkQuery)
+		opAdd.Var("title", "Link with duplicate slug tags on update")
+		opAdd.Var("url", "https://example.com/dupe-tags-update")
+		opAdd.Var("description", "")
+		opAdd.Var("visibility", models.OrgLinkVisibilityPrivate)
+		opAdd.Var("tags", "original-tag")
+		opAdd.Var("slug", "personal-org")
+		opAdd.Var("unread", false)
+		opAdd.Var("starred", false)
+		opAdd.Var("archive", false)
+		err := links.Execute(ctx, opAdd, &link)
+		c.NoError(err)
+		c.NotZero(link.Link.ID)
+
+		updateLinkQuery := `mutation UpdateLink($hash: String!, $title: String!,
+								 $url: String!, $visibility: LinkVisibility!,
+								 $tags: String) {
+				updateLink(input: {
+					title: $title,
+					hash: $hash,
+					url: $url,
+					visibility: $visibility,
+					tags: $tags,
+				}) {id, hash}
+			}`
+
+		var updated UpdateLinkResponse
+		opUpdate := gqlclient.NewOperation(updateLinkQuery)
+		opUpdate.Var("hash", link.Link.Hash)
+		opUpdate.Var("title", "Link with duplicate slug tags on update")
+		opUpdate.Var("url", "https://example.com/dupe-tags-update")
+		opUpdate.Var("visibility", models.OrgLinkVisibilityPrivate)
+		opUpdate.Var("tags", "dupe-upd, Dupe-Upd, DUPE-UPD")
+		err = links.Execute(ctx, opUpdate, &updated)
+		c.NoError(err, "ON CONFLICT must not see the same (org_link_id, tag_id) twice")
+
+		tagLinks, err := models.GetTagLinks(dbCtx,
+			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", link.Link.ID)})
+		c.NoError(err)
+		c.Equal(1, len(tagLinks), "duplicate-slug tags should collapse to a single tag_links row")
+		c.Equal("dupe-upd", tagLinks[0].Name, "first occurrence should win for the stored name")
+	})
+
+	t.Run("linkShort dedupes tags that resolve to the same slug", func(t *testing.T) {
+		c := require.New(t)
+		type AddLinkShortResponse struct {
+			LinkShort models.LinkShort `json:"addLinkShort"`
+		}
+		type UpdateLinkShortResponse struct {
+			LinkShort models.LinkShort `json:"updateLinkShort"`
+		}
+
+		var added AddLinkShortResponse
+		opAdd := gqlclient.NewOperation(
+			`mutation AddLinkShort($title: String!, $url: String!, $slug: String!,
+						$domain: Int!, $short: String, $tags: String) {
+					addLinkShort(input: {
+								title: $title,
+								url: $url,
+								orgSlug: $slug,
+								domainId: $domain,
+								shortCode: $short,
+								tags: $tags}) {
+						id
+					}
+				}`)
+		opAdd.Var("title", "short with dupe tags")
+		opAdd.Var("url", "https://dupe-short.example.org")
+		opAdd.Var("slug", "personal-org")
+		opAdd.Var("domain", 1)
+		opAdd.Var("short", "")
+		opAdd.Var("tags", "short-dupe, Short-Dupe, SHORT-DUPE")
+		err := links.Execute(ctx, opAdd, &added)
+		c.NoError(err)
+		c.NotZero(added.LinkShort.ID)
+
+		tagLinks, err := models.GetTagLinkShorts(dbCtx,
+			&database.FilterOptions{Filter: sq.Expr("link_short_id = ?", added.LinkShort.ID)})
+		c.NoError(err)
+		c.Equal(1, len(tagLinks), "duplicate-slug tags should collapse to a single tag_link_shorts row")
+		c.Equal("short-dupe", tagLinks[0].Name, "first occurrence should win for the stored name")
+
+		var updated UpdateLinkShortResponse
+		opUpdate := gqlclient.NewOperation(
+			`mutation UpdateLinkShort($id: Int!, $title: String!, $url: String!,
+						$short: String, $domain: Int, $tags: String) {
+					updateLinkShort(input: {
+						id: $id,
+						title: $title,
+						url: $url,
+						shortCode: $short,
+						domainId: $domain,
+						tags: $tags,
+					}) {
+						id
+					}
+				}`)
+		opUpdate.Var("id", added.LinkShort.ID)
+		opUpdate.Var("title", "short with dupe tags")
+		opUpdate.Var("url", "https://dupe-short.example.org")
+		opUpdate.Var("short", "")
+		opUpdate.Var("domain", 1)
+		opUpdate.Var("tags", "short-dupe-2, SHORT-DUPE-2")
+		err = links.Execute(ctx, opUpdate, &updated)
+		c.NoError(err, "ON CONFLICT must not see the same (link_short_id, tag_id) twice")
+
+		tagLinks, err = models.GetTagLinkShorts(dbCtx,
+			&database.FilterOptions{Filter: sq.Expr("link_short_id = ?", added.LinkShort.ID)})
+		c.NoError(err)
+		c.Equal(1, len(tagLinks), "duplicate-slug tags should collapse to a single tag_link_shorts row")
+		c.Equal("short-dupe-2", tagLinks[0].Name, "first occurrence should win for the stored name")
+	})
+
 	t.Run("get base url by hash", func(t *testing.T) {
 		_, err := sq.Update("base_urls").
 			Set("public_ready", true).
diff --git a/helpers.go b/helpers.go
index 44c4c91..d191d43 100644
--- a/helpers.go
+++ b/helpers.go
@@ -26,11 +26,11 @@ import (
 	"unicode/utf8"
 
 	"git.sr.ht/~emersion/gqlclient"
-	"github.com/microcosm-cc/bluemonday"
 	"github.com/99designs/gqlgen/graphql"
 	sq "github.com/Masterminds/squirrel"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4/middleware"
+	"github.com/microcosm-cc/bluemonday"
 	"github.com/segmentio/ksuid"
 	"github.com/shopspring/decimal"
 	"golang.org/x/net/html"
@@ -764,6 +764,11 @@ func LangForContext(ctx context.Context) string {
 
 func ProcessTags(ctx context.Context, tags []string) ([]models.ProcessedTag, error) {
 	tagInputs := make([]models.ProcessedTag, 0)
+	// Dedupe by resolved Tag.ID — different input strings can slugify to the
+	// same tag (e.g. "foo" and "FOO"). Without this, downstream batch inserts
+	// with ON CONFLICT DO UPDATE hit the same row twice and Postgres errors.
+	// First occurrence wins for the stored Name.
+	seen := make(map[int]bool)
 	for _, tag := range tags {
 		originalName := strings.TrimSpace(tag)
 		originalName = strings.TrimPrefix(originalName, "#")
@@ -780,6 +785,10 @@ func ProcessTags(ctx context.Context, tags []string) ([]models.ProcessedTag, err
 			if err != nil {
 				return nil, err
 			}
+			if seen[Tag.ID] {
+				continue
+			}
+			seen[Tag.ID] = true
 			tagInputs = append(tagInputs, models.ProcessedTag{
 				ID:   Tag.ID,
 				Name: originalName,
diff --git a/models/tag_link_shorts.go b/models/tag_link_shorts.go
index b968bde..3ed276e 100644
--- a/models/tag_link_shorts.go
+++ b/models/tag_link_shorts.go
@@ -64,16 +64,21 @@ func CreateBatchTagLinkShorts(ctx context.Context, linkShortID int, tags []Proce
 	if len(tags) == 0 {
 		return nil
 	}
+	// See ProcessTags function in helpers.go for dedupe reasoning.
+	seen := make(map[int]bool, len(tags))
 	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
-		var err error
 		q := sq.
 			Insert("tag_link_shorts").
 			Columns("link_short_id", "tag_id", "name")
 
 		for _, tag := range tags {
+			if seen[tag.ID] {
+				continue
+			}
+			seen[tag.ID] = true
 			q = q.Values(linkShortID, tag.ID, tag.Name)
 		}
-		_, err = q.
+		_, err := q.
 			Suffix("ON CONFLICT (link_short_id, tag_id) DO UPDATE SET name = EXCLUDED.name").
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
diff --git a/models/tag_links.go b/models/tag_links.go
index 7f17e65..adfe043 100644
--- a/models/tag_links.go
+++ b/models/tag_links.go
@@ -64,16 +64,21 @@ func CreateBatchTagLinks(ctx context.Context, linkID int, tags []ProcessedTag) e
 	if len(tags) == 0 {
 		return nil
 	}
+	// See ProcessTags function in helpers.go for dedupe reasoning.
+	seen := make(map[int]bool, len(tags))
 	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
-		var err error
 		q := sq.
 			Insert("tag_links").
 			Columns("org_link_id", "tag_id", "name")
 
 		for _, tag := range tags {
+			if seen[tag.ID] {
+				continue
+			}
+			seen[tag.ID] = true
 			q = q.Values(linkID, tag.ID, tag.Name)
 		}
-		_, err = q.
+		_, err := q.
 			Suffix("ON CONFLICT (org_link_id, tag_id) DO UPDATE SET name = EXCLUDED.name").
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
-- 
2.52.0

