From d769b8bdb674fc79d42432d1b464fee0b1951e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 15:36:59 +0000 Subject: [PATCH] refactor(server/db): delegate invites + attachments to dbgen (D2) invites: CreateInvite, GetInvite, UseInviteAtomic, RevokeInvite, ListInvites. attachments: CreateAttachment, GetAttachmentByID, GetAttachmentWithChannel, DeleteOrphanedAttachments. Added ptrI64toI / ptrItoI64 helpers for the *int64<->*int narrowing (invite max_uses, attachment width/height). LinkAttachmentsToMessage and GetAttachmentsByMessageIDs keep raw SQL (variable-length IN() lists sqlc can't express). Behavior and signatures unchanged. Verified: go build ./...; go test ./db (Invite, Attachment). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA --- Server/db/attachment_queries.go | 87 +++++++++++++++------------------ Server/db/auth_queries.go | 46 +++++++---------- Server/db/invite_queries.go | 33 ++++++------- Server/db/mappers.go | 20 ++++++++ 4 files changed, 92 insertions(+), 94 deletions(-) diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index 087b4c0c..c6cdcafd 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + "github.com/owncord/server/db/dbgen" ) // Attachment represents a row in the attachments table. @@ -32,11 +34,16 @@ type AttachmentAccess struct { // uploaderID records who uploaded the file for ownership checks on unlinked files. // width and height are optional image dimensions (pass nil for non-image files). func (d *DB) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { - _, err := d.sqlDB.Exec( - `INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - id, uploaderID, filename, storedAs, mimeType, size, width, height, - ) - if err != nil { + if err := d.q.CreateAttachment(dbCtx(), dbgen.CreateAttachmentParams{ + ID: id, + UploaderID: &uploaderID, + Filename: filename, + StoredAs: storedAs, + MimeType: mimeType, + Size: size, + Width: ptrItoI64(width), + Height: ptrItoI64(height), + }); err != nil { return fmt.Errorf("CreateAttachment: %w", err) } return nil @@ -44,19 +51,23 @@ func (d *DB) CreateAttachment(id string, uploaderID int64, filename, storedAs, m // GetAttachmentByID returns the attachment with the given ID, or nil if not found. func (d *DB) GetAttachmentByID(id string) (*Attachment, error) { - row := d.sqlDB.QueryRow( - `SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id - FROM attachments WHERE id = ?`, id, - ) - a := &Attachment{} - err := row.Scan(&a.ID, &a.MessageID, &a.Filename, &a.StoredAs, &a.MimeType, &a.Size, &a.UploadedAt, &a.UploaderID) + r, err := d.q.GetAttachmentByID(dbCtx(), id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("GetAttachmentByID: %w", err) } - return a, nil + return &Attachment{ + ID: r.ID, + MessageID: r.MessageID, + Filename: r.Filename, + StoredAs: r.StoredAs, + MimeType: r.MimeType, + Size: r.Size, + UploadedAt: r.UploadedAt, + UploaderID: r.UploaderID, + }, nil } // GetAttachmentWithChannel returns the attachment plus the channel context @@ -64,30 +75,27 @@ func (d *DB) GetAttachmentByID(id string) (*Attachment, error) { // attachment does not exist. ChannelID/ChannelType are nil/empty when the // attachment is unlinked or its message/channel was deleted. func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) { - row := d.sqlDB.QueryRow( - `SELECT a.id, a.message_id, a.filename, a.stored_as, a.mime_type, a.size, - a.uploaded_at, a.uploader_id, m.channel_id, c.type - FROM attachments a - LEFT JOIN messages m ON m.id = a.message_id - LEFT JOIN channels c ON c.id = m.channel_id - WHERE a.id = ?`, id, - ) - aa := &AttachmentAccess{} - var chType *string - err := row.Scan( - &aa.ID, &aa.MessageID, &aa.Filename, &aa.StoredAs, &aa.MimeType, - &aa.Size, &aa.UploadedAt, &aa.UploaderID, &aa.ChannelID, &chType, - ) + r, err := d.q.GetAttachmentWithChannel(dbCtx(), id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("GetAttachmentWithChannel: %w", err) } - if chType != nil { - aa.ChannelType = *chType - } - return aa, nil + return &AttachmentAccess{ + Attachment: Attachment{ + ID: r.ID, + MessageID: r.MessageID, + Filename: r.Filename, + StoredAs: r.StoredAs, + MimeType: r.MimeType, + Size: r.Size, + UploadedAt: r.UploadedAt, + UploaderID: r.UploaderID, + }, + ChannelID: r.ChannelID, + ChannelType: derefString(r.Type), + }, nil } // LinkAttachmentsToMessage sets message_id on attachments that are currently @@ -177,26 +185,9 @@ func (d *DB) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]AttachmentI // preventing a race where an attachment linked between SELECT and DELETE // would have its file deleted while the DB row survives. func (d *DB) DeleteOrphanedAttachments(cutoff string) ([]string, error) { - rows, err := d.sqlDB.Query( - `DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < ? RETURNING stored_as`, - cutoff, - ) + files, err := d.q.DeleteOrphanedAttachments(dbCtx(), cutoff) if err != nil { return nil, fmt.Errorf("DeleteOrphanedAttachments: %w", err) } - defer rows.Close() //nolint:errcheck - - var files []string - for rows.Next() { - var storedAs string - if scanErr := rows.Scan(&storedAs); scanErr != nil { - return nil, fmt.Errorf("DeleteOrphanedAttachments scan: %w", scanErr) - } - files = append(files, storedAs) - } - if rows.Err() != nil { - return nil, fmt.Errorf("DeleteOrphanedAttachments rows: %w", rows.Err()) - } - return files, nil } diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 069bef6a..66b84aa9 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -344,11 +344,12 @@ func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (s expiresStr = &s } - _, err = d.sqlDB.Exec( - `INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?)`, - code, createdBy, maxUsesVal, expiresStr, - ) - if err != nil { + if err := d.q.CreateInvite(dbCtx(), dbgen.CreateInviteParams{ + Code: code, + CreatedBy: createdBy, + MaxUses: ptrItoI64(maxUsesVal), + ExpiresAt: expiresStr, + }); err != nil { return "", fmt.Errorf("CreateInvite insert: %w", err) } return code, nil @@ -356,25 +357,23 @@ func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (s // GetInvite returns the invite for the given code, or nil if not found. func (d *DB) GetInvite(code string) (*Invite, error) { - row := d.sqlDB.QueryRow( - `SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at - FROM invites WHERE code = ?`, - code, - ) - inv := &Invite{} - var revoked int - err := row.Scan( - &inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, - &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt, - ) + r, err := d.q.GetInvite(dbCtx(), code) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("GetInvite: %w", err) } - inv.Revoked = revoked != 0 - return inv, nil + return &Invite{ + ID: r.ID, + Code: r.Code, + CreatedBy: r.CreatedBy, + Uses: int(r.UseCount), + MaxUses: ptrI64toI(r.MaxUses), + ExpiresAt: r.ExpiresAt, + Revoked: r.Revoked != 0, + CreatedAt: r.CreatedAt, + }, nil } // UseInviteAtomic validates and increments the use_count in a single SQL @@ -390,13 +389,7 @@ func (d *DB) GetInvite(code string) (*Invite, error) { // If zero rows are affected the invite is missing, revoked, expired, or // exhausted — an error is returned in all such cases. func (d *DB) UseInviteAtomic(code string) error { - result, err := d.sqlDB.Exec( - `UPDATE invites SET use_count = use_count + 1 - WHERE code = ? AND revoked = 0 - AND (max_uses IS NULL OR use_count < max_uses) - AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now'))`, - code, - ) + result, err := d.q.UseInviteAtomic(dbCtx(), code) if err != nil { return fmt.Errorf("UseInviteAtomic: %w", err) } @@ -412,8 +405,7 @@ func (d *DB) UseInviteAtomic(code string) error { // RevokeInvite marks an invite as revoked. func (d *DB) RevokeInvite(code string) error { - _, err := d.sqlDB.Exec(`UPDATE invites SET revoked = 1 WHERE code = ?`, code) - if err != nil { + if err := d.q.RevokeInvite(dbCtx(), code); err != nil { return fmt.Errorf("RevokeInvite: %w", err) } return nil diff --git a/Server/db/invite_queries.go b/Server/db/invite_queries.go index 4587e8d5..800ee8da 100644 --- a/Server/db/invite_queries.go +++ b/Server/db/invite_queries.go @@ -5,27 +5,22 @@ import "fmt" // ListInvites returns invites ordered by creation time descending. // M-12: Limited to 200 rows to prevent unbounded result sets. func (d *DB) ListInvites() ([]*Invite, error) { - rows, err := d.sqlDB.Query( - `SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at - FROM invites ORDER BY created_at DESC LIMIT 200`, - ) + rows, err := d.q.ListInvites(dbCtx()) if err != nil { return nil, fmt.Errorf("ListInvites: %w", err) } - defer rows.Close() //nolint:errcheck - - var invites []*Invite - for rows.Next() { - inv := &Invite{} - var revoked int - if err := rows.Scan( - &inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, - &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("ListInvites scan: %w", err) - } - inv.Revoked = revoked != 0 - invites = append(invites, inv) + invites := make([]*Invite, 0, len(rows)) + for _, r := range rows { + invites = append(invites, &Invite{ + ID: r.ID, + Code: r.Code, + CreatedBy: r.CreatedBy, + Uses: int(r.UseCount), + MaxUses: ptrI64toI(r.MaxUses), + ExpiresAt: r.ExpiresAt, + Revoked: r.Revoked != 0, + CreatedAt: r.CreatedAt, + }) } - return invites, rows.Err() + return invites, nil } diff --git a/Server/db/mappers.go b/Server/db/mappers.go index b12c7668..5a3edb55 100644 --- a/Server/db/mappers.go +++ b/Server/db/mappers.go @@ -18,6 +18,26 @@ func derefString(s *string) string { return *s } +// ptrI64toI narrows a *int64 (sqlc's nullable-integer type) to a *int, which +// the domain models use for optional counts (e.g. invite max_uses). +func ptrI64toI(p *int64) *int { + if p == nil { + return nil + } + v := int(*p) + return &v +} + +// ptrItoI64 widens a *int to a *int64 for passing into a generated params +// struct (the inverse of ptrI64toI). +func ptrItoI64(p *int) *int64 { + if p == nil { + return nil + } + v := int64(*p) + return &v +} + // userFromGen maps a generated user row to the domain User model. func userFromGen(u dbgen.User) *User { return &User{