0:00
/0:20

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:

  1. We can identify a default attachment.
  2. 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:

  1. Photos (First priority)
  2. Videos
  3. 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:

  1. Begin Transaction
  2. UPDATE current default $\rightarrow$ false
  3. UPDATE new target $\rightarrow$ true
  4. 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(&noteID); 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_default flag on raw attachments.
  • Uploads: The UploadItem interface was updated to capture isDefault from 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.