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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 15:36:59 +00:00
parent e46bd0e015
commit d769b8bdb6
4 changed files with 92 additions and 94 deletions
+39 -48
View File
@@ -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
}
+19 -27
View File
@@ -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
+14 -19
View File
@@ -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
}
+20
View File
@@ -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{