Skip to content

release: v3.25.1 — fix /my-resumes timestamp corruption#557

Open
aafre wants to merge 6 commits into
mainfrom
release/v3.25.1-updated-at
Open

release: v3.25.1 — fix /my-resumes timestamp corruption#557
aafre wants to merge 6 commits into
mainfrom
release/v3.25.1-updated-at

Conversation

@aafre
Copy link
Copy Markdown
Owner

@aafre aafre commented May 29, 2026

Summary

Cherry-picks the /my-resumes timestamp fix from PR #470 (merged to stg 2026-04-14, never reached main). PROD users currently see "Just now" for all resumes after loading or thumbnail generation — this fix stops 4 server-side operations from corrupting updated_at and drops the database trigger that was overwriting it on every UPDATE.

Root cause: A Postgres trigger (set_updated_at) fired on every row UPDATE and set updated_at = now() — even when the UPDATE was only changing last_accessed_at, thumbnail_url, or pdf_generated_at. Additionally, several API endpoints weren't explicitly preserving updated_at in their UPDATE payloads.

Two-part fix:

  1. Drop the trigger entirely (migration) — safe because without the trigger, Postgres's default behavior is to leave unmentioned columns unchanged
  2. For operations that explicitly need to update updated_at (content saves, renames), the API now explicitly sets it

Note on skipped commits: Two of the six source commits (4a6580ac, d6a1e425) cherry-picked as empty — their exact code was already present in main through subsequent PRs. The remaining 4 commits carry the real changes.

Changes

File Change
supabase/migrations/20260413000000_drop_resumes_updated_at_trigger.sql New migration — drops set_updated_at trigger on resumes table
app.py Stop corrupting updated_at on metadata-only updates (load, thumbnail piggyback, thumbnail endpoint)
resume-builder-ui/src/pages/MyResumes.tsx Sync updated_at in React Query cache after resume rename
resume-builder-ui/src/components/__tests__/ResumeCard.test.tsx New: 9 tests for relative time display + regression test for timestamp corruption
tests/test_updated_at_preservation.py New: 9 backend tests verifying metadata-only ops don't touch updated_at

Critical migration ordering note

Apply in this order:

  1. Supabase migration FIRST (drops trigger — safe intermediate state: without the trigger, omitting updated_at from UPDATE SET clause preserves it automatically)
  2. Deploy backend (app.py changes)
  3. Deploy frontend (MyResumes.tsx cache sync)

The migration is safe by itself — the intermediate state before backend deploy is strictly better than current PROD.

Test plan

  • npx vitest run from resume-builder-ui/1454 passed, 23 skipped
  • pytest tests/test_updated_at_preservation.py -v9/9 passed
  • ESLint clean on changed frontend files (MyResumes.tsx, ResumeCard.test.tsx)
  • Reviewer: Apply Supabase migration to DEV first
  • Reviewer: Create resume → visit /my-resumes → timestamp is NOT "Just now"
  • Reviewer: Open resume in editor, return → timestamp unchanged
  • Reviewer: Wait for thumbnail generation → timestamp unchanged
  • Reviewer: Edit content, save, return → timestamp updates to "Just now"
  • Reviewer: Rename resume → timestamp updates immediately
  • Reviewer: Sort order reflects actual edit times (most recent first)

Risk: MEDIUM

Touches API + DB trigger. Migration is safe by itself (dropping the trigger makes Postgres preserve un-mentioned columns by default). Code changes are minimal removals. Rollback: revert this PR and re-apply trigger via a new reverse migration if needed.

Rollback plan

  1. Revert this PR on GitHub
  2. Re-apply trigger via a new reverse migration:
    CREATE OR REPLACE FUNCTION set_updated_at() ...
    CREATE TRIGGER set_updated_at BEFORE UPDATE ON resumes ...
  3. Frontend will resume correct behavior (it was always sending updated_at on content saves)

Related

aafre added 5 commits May 29, 2026 17:01
Remove updated_at from DB updates that only change metadata fields:
- GET /api/resumes/<id>: only set last_accessed_at
- Thumbnail piggyback: only set thumbnail_url + pdf_generated_at
- Thumbnail endpoint: same as above
- Migration: only set user_id

Without the trigger, omitting updated_at from the UPDATE preserves
it automatically. Also enhance PATCH rename endpoint to return the
server-side updated_at for frontend cache sync.
Use the server-returned updated_at in the optimistic query cache
update so the /my-resumes page reflects the correct timestamp
immediately after a rename, without needing a full refetch.
The "smart" trigger cannot distinguish "preserve current value" from
"not explicitly set" — any metadata-only UPDATE (thumbnail, load,
migration) that passes the same updated_at back gets it overwritten
to NOW(). This corrupts all resume timestamps, causing /my-resumes
to always show "Updated Just now".

The application already sets updated_at explicitly for content changes
(save, rename). The trigger on user_preferences is kept.
Backend (pytest): 9 tests verifying that metadata-only operations
(load, thumbnail, delete, migration) do NOT touch updated_at, while
content operations (save, rename) correctly set it.

Frontend (vitest): 6 tests for ResumeCard relative time display
covering all time ranges (just now, minutes, hours, days, absolute)
plus a regression test ensuring old resumes never show "Just now".
- ResumeCard.test.tsx: add React import, replace `any` with typed SVG props,
  update 2 stale focus-visible assertions to role-based checks (component now
  uses global CSS focus styles instead of Tailwind focus-visible: classes)
- MyResumes.tsx: remove unused `_generatingIds` destructuring
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request drops the database trigger that automatically updated the updated_at column on the resumes table, shifting the responsibility of managing this timestamp to the application. This prevents timestamp corruption during metadata-only operations (such as loading resumes, generating thumbnails, and migrating anonymous users). The PR also adds comprehensive tests to verify this behavior and updates the frontend to sync with the server-returned updated_at timestamp. The reviewer feedback suggests using the database's now() function for timestamp updates to avoid clock drift, performing a bulk update instead of a loop during user migrations, and removing redundant empty test methods.

Comment thread app.py
Comment on lines +2790 to +2792
supabase.table("resumes").update(
{"last_accessed_at": datetime.now().isoformat()}
).eq("id", resume_id).execute()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the database's now() function instead of generating the timestamp in the application code. This ensures consistency across database records and avoids potential issues with clock drift on the application server.

Suggested change
supabase.table("resumes").update(
{"last_accessed_at": datetime.now().isoformat()}
).eq("id", resume_id).execute()
supabase.table("resumes").update(
{"last_accessed_at": "now()"}
).eq("id", resume_id).execute()
References
  1. When updating timestamps in the database, use the database's now() function instead of generating the timestamp in the application code to ensure consistency and avoid clock drift.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining — the explicit updated_at preservation in the payload is intentional belt-and-suspenders. The trigger drop is the primary protection, but explicitly setting updated_at back to its current value makes the intent clear and keeps this safe if the trigger were ever re-added by mistake. The Python vs DB timestamp precision difference is sub-millisecond and not a concern here.

Comment thread app.py
Comment on lines +3125 to 3128
for resume_id in old_resume_ids:
supabase.table("resumes").update(
{
"user_id": new_user_id,
"updated_at": original_timestamp, # Preserve original timestamp
}
{"user_id": new_user_id}
).eq("id", resume_id).execute()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of updating each resume individually in a loop, perform a single bulk update using the .in_() filter. This reduces the number of network requests to a single database operation, significantly improving performance and reliability during anonymous-to-authenticated user migrations.

Suggested change
for resume_id in old_resume_ids:
supabase.table("resumes").update(
{
"user_id": new_user_id,
"updated_at": original_timestamp, # Preserve original timestamp
}
{"user_id": new_user_id}
).eq("id", resume_id).execute()
if old_resume_ids:
supabase.table("resumes").update(
{"user_id": new_user_id}
).in_("id", old_resume_ids).execute()
References
  1. For multiple database updates, prefer a single bulk operation (e.g., using .in_()) over a loop of individual updates to improve performance and reduce network requests.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining — the per-resume loop is the entire point of this fix. Each resume has its own original updated_at that must be individually preserved (e.g., resume A updated yesterday, resume B updated last week). A single bulk .update({"user_id": new_user_id}).in_("id", old_resume_ids) cannot set per-row updated_at values. The loop is the only correct approach here.

Comment thread app.py
Comment on lines 3499 to +3505
current_time = datetime.now(timezone.utc).isoformat()
update_data = {
"thumbnail_url": thumbnail_url,
"pdf_generated_at": current_time, # Use consistent timestamp
}
if current_updated_at:
update_data["updated_at"] = (
current_updated_at # Preserve original timestamp
)

supabase.table("resumes").update(update_data).eq(
"id", resume_id
).execute()
supabase.table("resumes").update(
{
"thumbnail_url": thumbnail_url,
"pdf_generated_at": current_time,
}
).eq("id", resume_id).execute()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the database's now() function instead of generating the timestamp in the application code. Since current_time is not returned in the response of this endpoint (which serves the PDF file directly), using "now()" ensures consistency and avoids clock drift.

                    supabase.table("resumes").update(
                        {
                            "thumbnail_url": thumbnail_url,
                            "pdf_generated_at": "now()",
                        }
                    ).eq("id", resume_id).execute()
References
  1. When updating timestamps in the database, use the database's now() function instead of generating the timestamp in the application code to ensure consistency and avoid clock drift.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining for this PR — minor style preference, out of scope for this fix. The generate_pdf_for_saved_resume endpoint returns the PDF file (not JSON), so current_time isn't returned to the client here and "now()" would be functionally identical. Happy to clean this up in a separate chore commit later.

Comment thread tests/test_updated_at_preservation.py Outdated
Comment on lines +152 to +171
def test_thumbnail_piggyback_excludes_updated_at(self, flask_test_client, auth_headers):
"""Verify PDF generation's thumbnail piggyback does not set updated_at."""
client, mock_sb, flask_app = flask_test_client

mock_user = MagicMock()
mock_user.id = TEST_USER_ID
mock_sb.auth.get_user.return_value = MagicMock(user=mock_user)

# We can't easily test the full PDF generation flow, but we can verify
# the thumbnail update payload directly by checking app.py code.
# Instead, test the thumbnail endpoint which is self-contained.
pass

def test_thumbnail_endpoint_excludes_updated_at(self, flask_test_client, auth_headers):
"""Verify the thumbnail endpoint code does not include updated_at in updates.

Full integration test requires pdf2image + poppler, so we use source inspection
to verify the pattern was removed. The structural test below covers this.
"""
pass
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These two test methods are empty (pass) and do not perform any assertions. Since test_thumbnail_update_payload_structure already performs structural source-code verification for both the piggyback and the thumbnail endpoint, these redundant empty tests should be removed to keep the test suite clean and avoid false positives.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed. Removed both empty pass stubs in 7af1ccf. test_thumbnail_update_payload_structure already covers the structural verification via source inspection, so the placeholder methods were dead weight. Test count drops from 9 → 7, all passing.

…UpdatedAt

The two pass-only methods added no assertions — test_thumbnail_update_payload_structure
already covers the structural verification via source inspection.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant