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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user