all repos — weimar @ 088c2e33b9850fd8e9a0a65e6876d579b0f5433f

Unnamed repository; edit this file 'description' to name the repository.

internal/db/queries.go (view raw)

  1package db
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"fmt"
  7	"strings"
  8	"time"
  9)
 10
 11// ─────────────────────────── Users ───────────────────────────
 12
 13// CreateUser inserts a new user and returns it with the assigned ID.
 14func (d *DB) CreateUser(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) {
 15	admin := 0
 16	if isAdmin {
 17		admin = 1
 18	}
 19	t := now()
 20	res, err := d.db.ExecContext(ctx,
 21		`INSERT INTO users (username, password_hash, is_admin, created_at, updated_at)
 22		 VALUES (?, ?, ?, ?, ?)`,
 23		username, passwordHash, admin, t, t,
 24	)
 25	if err != nil {
 26		return nil, fmt.Errorf("create user: %w", err)
 27	}
 28	id, err := res.LastInsertId()
 29	if err != nil {
 30		return nil, fmt.Errorf("create user last insert id: %w", err)
 31	}
 32	return d.GetUserByID(ctx, id)
 33}
 34
 35// GetUserByID returns a single user by primary key.
 36func (d *DB) GetUserByID(ctx context.Context, id int64) (*User, error) {
 37	row := d.db.QueryRowContext(ctx,
 38		`SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path
 39		 FROM users WHERE id = ?`, id)
 40	return scanUser(row)
 41}
 42
 43// SetUserAvatar updates the avatar_path for a user. Pass nil to clear.
 44func (d *DB) SetUserAvatar(ctx context.Context, id int64, avatarPath *string) error {
 45	_, err := d.db.ExecContext(ctx,
 46		`UPDATE users SET avatar_path = ?, updated_at = ? WHERE id = ?`,
 47		avatarPath, now(), id)
 48	return err
 49}
 50
 51// GetUserByUsername returns a single user by username.
 52func (d *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) {
 53	row := d.db.QueryRowContext(ctx,
 54		`SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path
 55		 FROM users WHERE username = ?`, username)
 56	return scanUser(row)
 57}
 58
 59// ListUsers returns all users ordered by username.
 60func (d *DB) ListUsers(ctx context.Context) ([]User, error) {
 61	rows, err := d.db.QueryContext(ctx,
 62		`SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path
 63		 FROM users ORDER BY username`)
 64	if err != nil {
 65		return nil, fmt.Errorf("list users: %w", err)
 66	}
 67	defer rows.Close()
 68
 69	users := make([]User, 0)
 70	for rows.Next() {
 71		u, err := scanUser(rows)
 72		if err != nil {
 73			return nil, err
 74		}
 75		users = append(users, *u)
 76	}
 77	return users, rows.Err()
 78}
 79
 80// DeleteUser removes a user by ID. Returns sql.ErrNoRows if not found.
 81func (d *DB) DeleteUser(ctx context.Context, id int64) error {
 82	res, err := d.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
 83	if err != nil {
 84		return fmt.Errorf("delete user: %w", err)
 85	}
 86	n, _ := res.RowsAffected()
 87	if n == 0 {
 88		return sql.ErrNoRows
 89	}
 90	return nil
 91}
 92
 93// UpdateUserPassword sets a new password hash for the user.
 94func (d *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error {
 95	res, err := d.db.ExecContext(ctx,
 96		`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`,
 97		passwordHash, now(), id)
 98	if err != nil {
 99		return fmt.Errorf("update password: %w", err)
100	}
101	n, _ := res.RowsAffected()
102	if n == 0 {
103		return sql.ErrNoRows
104	}
105	return nil
106}
107
108type userScanner interface {
109	Scan(dest ...any) error
110}
111
112func scanUser(s userScanner) (*User, error) {
113	var u User
114	var isAdmin int
115	var createdAt, updatedAt string
116	var avatarPath *string
117	if err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &isAdmin, &createdAt, &updatedAt, &avatarPath); err != nil {
118		return nil, err
119	}
120	u.IsAdmin = isAdmin == 1
121	u.CreatedAt, _ = scanTime(createdAt)
122	u.UpdatedAt, _ = scanTime(updatedAt)
123	u.AvatarPath = avatarPath
124	return &u, nil
125}
126
127// ─────────────────────────── Sessions ───────────────────────────
128
129// CreateSession inserts a new session and returns it.
130func (d *DB) CreateSession(ctx context.Context, userID int64, token string, expiresAt time.Time) (*Session, error) {
131	t := now()
132	res, err := d.db.ExecContext(ctx,
133		`INSERT INTO sessions (user_id, token, expires_at, created_at)
134		 VALUES (?, ?, ?, ?)`,
135		userID, token, expiresAt.UTC().Format(time.RFC3339), t,
136	)
137	if err != nil {
138		return nil, fmt.Errorf("create session: %w", err)
139	}
140	id, err := res.LastInsertId()
141	if err != nil {
142		return nil, fmt.Errorf("create session last insert id: %w", err)
143	}
144
145	return &Session{
146		ID:        id,
147		UserID:    userID,
148		Token:     token,
149		ExpiresAt: expiresAt,
150	}, nil
151}
152
153// GetSessionByToken retrieves a session by its token.
154func (d *DB) GetSessionByToken(ctx context.Context, token string) (*Session, error) {
155	row := d.db.QueryRowContext(ctx,
156		`SELECT id, user_id, token, expires_at, created_at
157		 FROM sessions WHERE token = ?`, token)
158	return scanSession(row)
159}
160
161// DeleteSession removes a session by token.
162func (d *DB) DeleteSession(ctx context.Context, token string) error {
163	_, err := d.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token)
164	return err
165}
166
167// DeleteUserSessions removes all sessions for a given user.
168func (d *DB) DeleteUserSessions(ctx context.Context, userID int64) error {
169	_, err := d.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
170	return err
171}
172
173// ExtendSession sets a new expiration time on the session.
174func (d *DB) ExtendSession(ctx context.Context, token string, expiresAt time.Time) error {
175	_, err := d.db.ExecContext(ctx,
176		`UPDATE sessions SET expires_at = ? WHERE token = ?`,
177		expiresAt.UTC().Format(time.RFC3339), token)
178	return err
179}
180
181type sessionScanner interface {
182	Scan(dest ...any) error
183}
184
185func scanSession(s sessionScanner) (*Session, error) {
186	var sess Session
187	var createdAt, expiresAt string
188	if err := s.Scan(&sess.ID, &sess.UserID, &sess.Token, &expiresAt, &createdAt); err != nil {
189		return nil, err
190	}
191	sess.ExpiresAt, _ = scanTime(expiresAt)
192	sess.CreatedAt, _ = scanTime(createdAt)
193	return &sess, nil
194}
195
196// ─────────────────────────── Images ───────────────────────────
197
198// CreateImage inserts a new image record.
199func (d *DB) CreateImage(ctx context.Context, img *Image) (*Image, error) {
200	t := now()
201	res, err := d.db.ExecContext(ctx,
202		`INSERT INTO images (filename, storage_path, thumbnail_path, uploaded_by,
203		                     file_size, width, height, mime_type, created_at, updated_at)
204		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
205		img.Filename, img.StoragePath, img.ThumbnailPath, img.UploadedBy,
206		img.FileSize, img.Width, img.Height, img.MimeType, t, t,
207	)
208	if err != nil {
209		return nil, fmt.Errorf("create image: %w", err)
210	}
211	id, err := res.LastInsertId()
212	if err != nil {
213		return nil, fmt.Errorf("create image last insert id: %w", err)
214	}
215	return d.GetImageByID(ctx, id)
216}
217
218// GetImageByID returns a single image by primary key.
219func (d *DB) GetImageByID(ctx context.Context, id int64) (*Image, error) {
220	row := d.db.QueryRowContext(ctx,
221		`SELECT id, filename, storage_path, thumbnail_path,
222		        uploaded_by, file_size, width, height, mime_type,
223		        created_at, updated_at
224		 FROM images WHERE id = ?`, id)
225	img, err := scanImage(row)
226	if err != nil {
227		return nil, err
228	}
229
230	// Load tags for this image.
231	tags, err := d.GetImageTags(ctx, id)
232	if err != nil {
233		return nil, err
234	}
235	img.Tags = tags
236	return img, nil
237}
238
239// ListImages returns a paginated list of images, optionally filtered by tags
240// and/or uploader username. Multiple tags are AND-ed (image must have all
241// specified tags). Order is newest-first by created_at.
242func (d *DB) ListImages(ctx context.Context, filterTags []string, filterUploader string, limit, offset int) (*ImageList, error) {
243	// Count query
244	countQuery := `SELECT COUNT(DISTINCT i.id) FROM images i`
245	// Data query
246	dataQuery := `SELECT i.id, i.filename, i.storage_path, i.thumbnail_path,
247	                     i.uploaded_by, i.file_size, i.width, i.height, i.mime_type,
248	                     i.created_at, i.updated_at
249	              FROM images i`
250
251	var args []any
252	var tagJoin string
253	var uploaderWhere string
254	var tagWhere string
255
256	if filterUploader != "" {
257		uploaderWhere = `i.uploaded_by = (SELECT id FROM users WHERE username = ?)`
258		args = append(args, filterUploader)
259	}
260
261	if len(filterTags) > 0 {
262		placeholders := make([]string, len(filterTags))
263		for j, t := range filterTags {
264			placeholders[j] = "?"
265			args = append(args, t)
266		}
267		tagJoin = ` JOIN image_tags it ON i.id = it.image_id`
268		tagWhere = `it.tag IN (` + strings.Join(placeholders, ",") + `)`
269	}
270
271	// Build WHERE clause.
272	var whereClause string
273	switch {
274	case tagWhere != "" && uploaderWhere != "":
275		whereClause = ` WHERE ` + tagWhere + ` AND ` + uploaderWhere
276	case tagWhere != "":
277		whereClause = ` WHERE ` + tagWhere
278	case uploaderWhere != "":
279		whereClause = ` WHERE ` + uploaderWhere
280	}
281
282	countQuery += tagJoin + whereClause
283	dataQuery += tagJoin + whereClause
284
285	if len(filterTags) > 0 {
286		dataQuery += ` GROUP BY i.id HAVING COUNT(DISTINCT it.tag) = ` + fmt.Sprintf("%d", len(filterTags))
287	}
288
289	dataQuery += ` ORDER BY i.created_at DESC LIMIT ? OFFSET ?`
290	argsData := make([]any, len(args))
291	copy(argsData, args)
292	argsData = append(argsData, limit, offset)
293	args = append(args, limit, offset)
294
295	// Execute count.
296	var total int
297	if err := d.db.QueryRowContext(ctx, countQuery, argsData[:len(argsData)-2]...).Scan(&total); err != nil {
298		return nil, fmt.Errorf("list images count: %w", err)
299	}
300
301	// Execute data.
302	rows, err := d.db.QueryContext(ctx, dataQuery, argsData...)
303	if err != nil {
304		return nil, fmt.Errorf("list images: %w", err)
305	}
306	defer rows.Close()
307
308	images := make([]Image, 0)
309	for rows.Next() {
310		img, err := scanImage(rows)
311		if err != nil {
312			return nil, err
313		}
314		images = append(images, *img)
315	}
316	if err := rows.Err(); err != nil {
317		return nil, err
318	}
319
320	return &ImageList{Images: images, Total: total}, nil
321}
322
323// DeleteImage removes an image record (tags cleaned via ON DELETE CASCADE).
324func (d *DB) DeleteImage(ctx context.Context, id int64) error {
325	// First decrement usage counts for associated tags.
326	tags, err := d.GetImageTags(ctx, id)
327	if err != nil {
328		return err
329	}
330	for _, tag := range tags {
331		if err := d.decrementTagUsage(ctx, tag); err != nil {
332			return err
333		}
334	}
335
336	res, err := d.db.ExecContext(ctx, `DELETE FROM images WHERE id = ?`, id)
337	if err != nil {
338		return fmt.Errorf("delete image: %w", err)
339	}
340	n, _ := res.RowsAffected()
341	if n == 0 {
342		return sql.ErrNoRows
343	}
344	return nil
345}
346
347type imageScanner interface {
348	Scan(dest ...any) error
349}
350
351func scanImage(s imageScanner) (*Image, error) {
352	var img Image
353	var thumbnailPath, createdAt, updatedAt sql.NullString
354	if err := s.Scan(
355		&img.ID, &img.Filename, &img.StoragePath, &thumbnailPath,
356		&img.UploadedBy, &img.FileSize, &img.Width, &img.Height, &img.MimeType,
357		&createdAt, &updatedAt,
358	); err != nil {
359		return nil, err
360	}
361	if thumbnailPath.Valid {
362		img.ThumbnailPath = &thumbnailPath.String
363	}
364	if createdAt.Valid {
365		img.CreatedAt, _ = scanTime(createdAt.String)
366	}
367	if updatedAt.Valid {
368		img.UpdatedAt, _ = scanTime(updatedAt.String)
369	}
370	return &img, nil
371}
372
373// ─────────────────────────── Tags ───────────────────────────
374
375// SetImageTags replaces all tags for an image and updates usage counts.
376func (d *DB) SetImageTags(ctx context.Context, imageID int64, tags []string) error {
377	// Get existing tags to decrement counts.
378	oldTags, err := d.GetImageTags(ctx, imageID)
379	if err != nil {
380		return err
381	}
382
383	// Remove old associations.
384	if _, err := d.db.ExecContext(ctx, `DELETE FROM image_tags WHERE image_id = ?`, imageID); err != nil {
385		return fmt.Errorf("clear image tags: %w", err)
386	}
387
388	for _, t := range oldTags {
389		if err := d.decrementTagUsage(ctx, t); err != nil {
390			return err
391		}
392	}
393
394	// Insert new tags.
395	for _, t := range tags {
396		tag := normalizeTag(t)
397		if tag == "" {
398			continue
399		}
400		if _, err := d.db.ExecContext(ctx,
401			`INSERT INTO image_tags (image_id, tag) VALUES (?, ?)`, imageID, tag,
402		); err != nil {
403			return fmt.Errorf("insert image tag: %w", err)
404		}
405		if err := d.incrementTagUsage(ctx, tag); err != nil {
406			return err
407		}
408	}
409
410	return nil
411}
412
413// GetImageTags returns the tag strings for a given image.
414func (d *DB) GetImageTags(ctx context.Context, imageID int64) ([]string, error) {
415	rows, err := d.db.QueryContext(ctx,
416		`SELECT tag FROM image_tags WHERE image_id = ? ORDER BY tag`, imageID)
417	if err != nil {
418		return nil, fmt.Errorf("get image tags: %w", err)
419	}
420	defer rows.Close()
421
422	tags := make([]string, 0)
423	for rows.Next() {
424		var t string
425		if err := rows.Scan(&t); err != nil {
426			return nil, err
427		}
428		tags = append(tags, t)
429	}
430	return tags, rows.Err()
431}
432
433// GetImageTagsWithCounts returns tags and their usage counts for a given image,
434// ordered by most frequent first.
435func (d *DB) GetImageTagsWithCounts(ctx context.Context, imageID int64) ([]Tag, error) {
436	rows, err := d.db.QueryContext(ctx,
437		`SELECT t.tag, t.usage_count
438		 FROM tags t
439		 JOIN image_tags it ON it.tag = t.tag
440		 WHERE it.image_id = ?
441		 ORDER BY t.usage_count DESC, t.tag`, imageID)
442	if err != nil {
443		return nil, fmt.Errorf("get image tags with counts: %w", err)
444	}
445	defer rows.Close()
446
447	tags := make([]Tag, 0)
448	for rows.Next() {
449		var t Tag
450		if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil {
451			return nil, err
452		}
453		tags = append(tags, t)
454	}
455	return tags, rows.Err()
456}
457
458// SuggestTags returns tags matching a prefix, ordered by usage count descending.
459// SuggestUploaders returns usernames matching a prefix, ordered by upload count descending.
460func (d *DB) SuggestUploaders(ctx context.Context, prefix string, limit int) ([]Tag, error) {
461	rows, err := d.db.QueryContext(ctx,
462		`SELECT u.username, COUNT(i.id) AS cnt
463		 FROM users u
464		 LEFT JOIN images i ON i.uploaded_by = u.id
465		 WHERE u.username LIKE ?
466		 GROUP BY u.id, u.username
467		 ORDER BY cnt DESC, u.username
468		 LIMIT ?`,
469		prefix+"%", limit)
470	if err != nil {
471		return nil, fmt.Errorf("suggest uploaders: %w", err)
472	}
473	defer rows.Close()
474
475	users := make([]Tag, 0)
476	for rows.Next() {
477		var t Tag
478		if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil {
479			return nil, err
480		}
481		users = append(users, t)
482	}
483	return users, rows.Err()
484}
485
486func (d *DB) SuggestTags(ctx context.Context, prefix string, limit int) ([]Tag, error) {
487	rows, err := d.db.QueryContext(ctx,
488		`SELECT tag, usage_count FROM tags WHERE tag LIKE ? ORDER BY usage_count DESC LIMIT ?`,
489		prefix+"%", limit)
490	if err != nil {
491		return nil, fmt.Errorf("suggest tags: %w", err)
492	}
493	defer rows.Close()
494
495	tags := make([]Tag, 0)
496	for rows.Next() {
497		var t Tag
498		if err := rows.Scan(&t.Tag, &t.UsageCount); err != nil {
499			return nil, err
500		}
501		tags = append(tags, t)
502	}
503	return tags, rows.Err()
504}
505
506func (d *DB) incrementTagUsage(ctx context.Context, tag string) error {
507	_, err := d.db.ExecContext(ctx,
508		`INSERT INTO tags (tag, usage_count) VALUES (?, 1)
509		 ON CONFLICT(tag) DO UPDATE SET usage_count = usage_count + 1`,
510		tag)
511	return err
512}
513
514func (d *DB) decrementTagUsage(ctx context.Context, tag string) error {
515	_, err := d.db.ExecContext(ctx,
516		`UPDATE tags SET usage_count = usage_count - 1 WHERE tag = ? AND usage_count > 0`,
517		tag)
518	if err != nil {
519		return err
520	}
521	// Clean up tags with zero usage.
522	_, err = d.db.ExecContext(ctx, `DELETE FROM tags WHERE tag = ? AND usage_count <= 0`, tag)
523	return err
524}
525
526// AddImageTags adds tags to an image, skipping existing ones, and increments usage counts.
527func (d *DB) AddImageTags(ctx context.Context, imageID int64, tags []string) error {
528	for _, t := range tags {
529		tag := normalizeTag(t)
530		if tag == "" {
531			continue
532		}
533		// Check if tag already exists for this image.
534		var count int
535		if err := d.db.QueryRowContext(ctx,
536			`SELECT COUNT(*) FROM image_tags WHERE image_id = ? AND tag = ?`,
537			imageID, tag).Scan(&count); err != nil {
538			return fmt.Errorf("check existing tag: %w", err)
539		}
540		if count > 0 {
541			continue
542		}
543		if _, err := d.db.ExecContext(ctx,
544			`INSERT INTO image_tags (image_id, tag) VALUES (?, ?)`, imageID, tag,
545		); err != nil {
546			return fmt.Errorf("insert image tag: %w", err)
547		}
548		if err := d.incrementTagUsage(ctx, tag); err != nil {
549			return err
550		}
551	}
552	return nil
553}
554
555// RemoveImageTags removes specific tags from an image and decrements usage counts.
556func (d *DB) RemoveImageTags(ctx context.Context, imageID int64, tags []string) error {
557	for _, t := range tags {
558		tag := normalizeTag(t)
559		if tag == "" {
560			continue
561		}
562		res, err := d.db.ExecContext(ctx,
563			`DELETE FROM image_tags WHERE image_id = ? AND tag = ?`, imageID, tag)
564		if err != nil {
565			return fmt.Errorf("remove image tag: %w", err)
566		}
567		n, _ := res.RowsAffected()
568		if n > 0 {
569			if err := d.decrementTagUsage(ctx, tag); err != nil {
570				return err
571			}
572		}
573	}
574	return nil
575}
576
577// normalizeTag trims whitespace, lowercases, and collapses internal whitespace.
578func normalizeTag(tag string) string {
579	return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(tag))), " ")
580}