feat(backend): update portfolio images handler

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:37 +01:00
co-authored by Sisyphus
parent aeab289be9
commit 6ff35a5cc7
2 changed files with 360 additions and 56 deletions
+102 -34
View File
@@ -119,6 +119,13 @@ type Tag struct {
Name string `json:"name"`
}
// parseCursor splits a "createdAt|id" cursor string into its components.
type ImageListResponse struct {
Images []Image `json:"images"`
NextCursor *string `json:"next_cursor,omitempty"`
}
func ListImages(w http.ResponseWriter, r *http.Request) {
tagFilter := r.URL.Query().Get("tag")
tagsFilter := r.URL.Query().Get("tags")
@@ -138,18 +145,13 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
}
limit := 20
offset := 0
cursorStr := r.URL.Query().Get("cursor")
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 && parsed <= 10000 {
offset = parsed
}
}
// Validate filter categories against allowed list from DB
allowedCategories, err := getAllowedCategories(r.Context())
@@ -184,7 +186,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
return
}
filterClauses += fmt.Sprintf(" AND $%d = ANY(tag_names)", len(filterArgs)+1)
filterClauses += fmt.Sprintf(" AND $%d::text = ANY(tag_names)", len(filterArgs)+1)
filterArgs = append(filterArgs, category+":"+value)
}
}
@@ -199,7 +201,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
tagList := strings.Split(tagsFilter, ",")
cleanTags := make([]string, len(tagList))
for i, t := range tagList {
cleanTags[i] = strings.TrimSpace(t)
cleanTags[i] = "%" + strings.TrimSpace(t) + "%"
}
conditions := make([]string, len(cleanTags))
for i := range cleanTags {
@@ -219,37 +221,68 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
%s as relevance
FROM images, unnest(tag_names) as t
WHERE %s%s
GROUP BY id, url, thumbnail_url, tag_names, created_at, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url
ORDER BY match_count DESC, relevance DESC, created_at DESC
LIMIT $%d OFFSET $%d
`, formatCols, similaritySum, whereClause, filterClauses, argOffset+len(cleanTags)+1, argOffset+len(cleanTags)+2)
GROUP BY id
`, formatCols, similaritySum, whereClause, filterClauses)
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+2)
var cursorArgs []interface{}
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
cursorArgs = []interface{}{cursorCreatedAt, cursorID}
havingIdx := len(cleanTags) + len(filterArgs) + 1
query += fmt.Sprintf(" HAVING (created_at, id) < ($%d, $%d)", havingIdx, havingIdx+1)
}
query += " ORDER BY match_count DESC, relevance DESC, created_at DESC, id DESC"
argOffset = len(filterArgs) + len(cleanTags) + len(cursorArgs)
query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+len(cursorArgs)+1)
copy(queryArgs, filterArgs)
for i, t := range cleanTags {
queryArgs[len(filterArgs)+i] = t
}
queryArgs[len(filterArgs)+len(cleanTags)] = limit
queryArgs[len(filterArgs)+len(cleanTags)+1] = offset
for i, ca := range cursorArgs {
queryArgs[len(filterArgs)+len(cleanTags)+i] = ca
}
queryArgs[len(filterArgs)+len(cleanTags)+len(cursorArgs)] = limit
args = queryArgs
} else if tagFilter != "" {
argOffset := len(filterArgs)
searchPattern := "%" + tagFilter + "%"
searchIdx := argOffset + 1
query = fmt.Sprintf(`
SELECT id, url, thumbnail_url, tag_names, created_at%s,
CASE WHEN t = $1 THEN 2 ELSE 1 END as match_priority,
similarity(t, $1) as relevance
CASE WHEN t = $%d THEN 2 ELSE 1 END as match_priority,
similarity(t, $%d) as relevance
FROM images, unnest(tag_names) as t
WHERE t ILIKE '%%' || $1 || '%%%s'
ORDER BY match_priority DESC, relevance DESC, created_at DESC
LIMIT $%d OFFSET $%d
`, formatCols, filterClauses, argOffset+2, argOffset+3)
WHERE 1=1%s AND t ILIKE '%%' || $%d || '%%'
`, formatCols, searchIdx, searchIdx, filterClauses, searchIdx)
queryArgs := make([]interface{}, len(filterArgs)+3)
queryArgs[0] = searchPattern
copy(queryArgs[1:], filterArgs)
queryArgs[len(filterArgs)+1] = limit
queryArgs[len(filterArgs)+2] = offset
cursorArgs := []interface{}{}
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
query += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", searchIdx+1, searchIdx+2)
cursorArgs = append(cursorArgs, cursorCreatedAt, cursorID)
}
query += " ORDER BY match_priority DESC, relevance DESC, created_at DESC, id DESC"
query += fmt.Sprintf(" LIMIT $%d", searchIdx+1+len(cursorArgs))
queryArgs := make([]interface{}, searchIdx+1+len(cursorArgs))
copy(queryArgs[:argOffset], filterArgs)
queryArgs[argOffset] = searchPattern
for i, ca := range cursorArgs {
queryArgs[searchIdx+i] = ca
}
queryArgs[searchIdx+len(cursorArgs)] = limit
args = queryArgs
} else {
argOffset := len(filterArgs)
@@ -257,14 +290,25 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
SELECT id, url, thumbnail_url, tag_names, created_at%s, 0 as match_count, 0.0 as relevance
FROM images
WHERE 1=1%s
ORDER BY created_at DESC
LIMIT $%d OFFSET $%d
`, formatCols, filterClauses, argOffset+1, argOffset+2)
`, formatCols, filterClauses)
queryArgs := make([]interface{}, len(filterArgs)+2)
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
query += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", argOffset+1, argOffset+2)
filterArgs = append(filterArgs, cursorCreatedAt, cursorID)
argOffset += 2
}
query += " ORDER BY created_at DESC, id DESC"
query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
queryArgs := make([]interface{}, len(filterArgs)+1)
copy(queryArgs, filterArgs)
queryArgs[len(filterArgs)] = limit
queryArgs[len(filterArgs)+1] = offset
args = queryArgs
}
@@ -321,8 +365,18 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
images = []Image{}
}
var nextCursor *string
if len(images) > 0 {
last := images[len(images)-1]
cursor := last.CreatedAt.UTC().Format(time.RFC3339) + "|" + last.ID
nextCursor = &cursor
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(images)
json.NewEncoder(w).Encode(ImageListResponse{
Images: images,
NextCursor: nextCursor,
})
}
func ListTags(w http.ResponseWriter, r *http.Request) {
@@ -794,8 +848,16 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
}
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
var imgID string
err := db.DB.QueryRow(r.Context(), `
err = tx.QueryRow(r.Context(), `
INSERT INTO images (url, thumbnail_url, tag_names, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
@@ -811,7 +873,7 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
if tag == "" {
continue
}
_, err = db.DB.Exec(r.Context(), `
_, err = tx.Exec(r.Context(), `
INSERT INTO tags (name) VALUES ($1)
ON CONFLICT (name) DO NOTHING
`, tag)
@@ -820,6 +882,12 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Image{
ID: imgID,