When users attach multiple media files to a note—photos, videos, voice memos—the system needs to know which one to display as the primary thumbnail. Previously, this was arbitrary.
We implemented a Default Media Attachment system. This full-stack update ensures that every note has a deliberate "hero" asset, with smart fallback logic if that asset is deleted.
Here is the technical breakdown of how I built it, from the database up to the UI.
1. The Database Strategy: Strict Constraints
We started by modifying the note_attachments table. The goal was to ensure two things:
- We can identify a default attachment.
- Constraint: A note can have only one default attachment at a time.
I handled this via a migration that adds an is_default boolean and a partial unique index.
-- Migration: 007_add_is_default_to_note_attachments.sql
-- 1. Add the flag
ALTER TABLE note_attachments ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT false;
-- 2. Enforce one default per note
CREATE UNIQUE INDEX one_default_per_note
ON note_attachments (note_id)
WHERE (is_default = true);The Backfill Strategy: We couldn't just leave existing data in a limbo state. I wrote a backfill script within the migration that assigns a default to existing notes based on a specific hierarchy: Photo > Video > Audio.
2. Backend Logic (Go): Auto-Promotion & Transactions
In the Go backend, I introduced two core pieces of logic to handle state management.
The "Auto-Promote" Algorithm
We never want a note to be "default-less" if it has media. I added an AutoPromoteDefault function in fellow.go.
If a user deletes their current default image, the system doesn't just remove it; it scans the remaining attachments and promotes the next best candidate based on the same hierarchy used in the migration:
- Photos (First priority)
- Videos
- Audio (Last priority)
Transactional Switching
When a user explicitly clicks to "pin" a new image as the default, we have to perform a swap. To prevent race conditions or constraint violations (remember the unique index!), this is wrapped in a database transaction:
- Begin Transaction
UPDATEcurrent default $\rightarrow$falseUPDATEnew target $\rightarrow$true- Commit
This logic is exposed via a new endpoint: PUT /notes/attachments/default.
// SetDefaultNoteAttachment explicitly sets a given attachment as the default for its note.
// Unsets any existing default for that note first, within a transaction.
func SetDefaultNoteAttachment(ctx context.Context, userID, attachmentID uuid.UUID) (*types.NoteAttachment, error) {
// First, get the attachment to find its note_id and verify ownership
var noteID uuid.UUID
lookupQuery := `SELECT note_id FROM note_attachments WHERE id = $1 AND user_id = $2`
if err := db.QueryRowContext(ctx, lookupQuery, attachmentID, userID).Scan(¬eID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("attachment not found")
}
return nil, fmt.Errorf("lookup attachment: %w", err)
}
// Use a transaction to atomically unset old default and set new one
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Unset any existing default for this note
_, err = tx.ExecContext(ctx,
`UPDATE note_attachments SET is_default = false WHERE note_id = $1 AND is_default = true`,
noteID,
)
if err != nil {
return nil, fmt.Errorf("unset old default: %w", err)
}
// Set the new default
var attachment types.NoteAttachment
setQuery := `
UPDATE note_attachments
SET is_default = true
WHERE id = $1 AND user_id = $2
RETURNING id, note_id, user_id, file_type, file_url, file_key, is_default, created_at, updated_at
`
if err := tx.QueryRowContext(ctx, setQuery, attachmentID, userID).Scan(
&attachment.ID,
&attachment.NoteID,
&attachment.UserID,
&attachment.FileType,
&attachment.FileURL,
&attachment.FileKey,
&attachment.IsDefault,
&attachment.CreatedAt,
&attachment.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("set new default: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit set default: %w", err)
}
return &attachment, nil
}
3. Frontend Implementation (React/TypeScript)
On the client side, the focus was on providing immediate visual feedback without cluttering the UI.
The UX: The Amber Pin
We updated the NoteForm component to overlay a Pin Icon on media thumbnails.
- Active Default: Displays a solid Amber pin.
- Inactive: Displays a semi-transparent pin on hover.
Handling the Upload Lifecycle
The tricky part of the frontend was unifying the state between existing attachments and new uploads.
- Initialization: When the form loads, we check the
is_defaultflag on raw attachments. - Uploads: The
UploadIteminterface was updated to captureisDefaultfrom the API response immediately after a file finishes uploading. - User Action: Clicking the pin triggers the API call. On success, we update the local state to shift the amber pin to the new owner, giving the user instant confirmation.
4. Verification & Testing
To ensure stability, the feature went through a three-layer verification process:
| Layer | Verification Method | Status |
|---|---|---|
| Build | Go and TypeScript compilation checks | ✅ Pass |
| Data | Migration execution against local DB replicas | ✅ Pass |
| Manual | Full lifecycle testing (Upload → Pin → Delete → Auto-promote) | ✅ Pass |
Next Steps
The feature is currently stable in the local environment. The next immediate step is deploying the migration to staging and verifying that the AutoPromote backfill handles the production dataset volume efficiently.