Short IDs become the canonical identifier in URLs (/i/:short_id), MinIO/R2 storage keys, and all API responses. Hash-based deduplication is preserved. Includes two-phase Alembic migration (003 adds nullable column, 004 enforces NOT NULL) with a backfill script to copy storage objects and populate short_id for existing images. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
17 KiB
Tasks: Short Image IDs
Input: Design documents from specs/017-short-id-migration/
Prerequisites: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/image-api.md ✅, quickstart.md ✅
Tests: Tests accompany each implementation task per §5.1. All API changes are in api/, all UI changes are in ui/src/app/.
Organization: The foundational phase (Phase 1) must complete before any user story work begins — it adds the short_id column, model field, utility function, and repository method that all three user stories depend on. US1 and US2 can then proceed; US3 (migration script) follows last because it operates on the fully wired system.
Format: [ID] [P?] [Story] Description
- [P]: Can run in parallel (different files, no dependencies)
- [Story]: Which user story this task belongs to
Phase 1: Foundational — Short ID Infrastructure (Blocks All User Stories)
Goal: Add the short_id column to the database, the model, a generation utility, and a repository lookup. Every user story depends on these.
Independent Test: After this phase, generate_short_id() can be called from a Python shell and returns an 8-character alphanumeric string. alembic upgrade head applies migration 003 cleanly. A manually inserted image with a short_id can be fetched by image_repo.get_by_short_id() in a test.
-
T001 Write failing unit tests for
generate_short_id()inapi/tests/unit/test_hashing.py: (1) returns exactly 8 characters; (2) contains only[a-zA-Z0-9]characters; (3) two consecutive calls return different values (collision test); (4) function exists and is importable fromapp.utils. Runmake test-unitand confirm new tests FAIL. -
T002 Add
generate_short_id()toapi/app/utils.py: importsecretsandstring; defineBASE62 = string.ascii_letters + string.digits; implementdef generate_short_id(length: int = 8) -> str: return ''.join(secrets.choice(BASE62) for _ in range(length)). Runmake test-unitand confirm T001 tests pass. -
T003 Create Alembic migration
api/alembic/versions/003_add_short_id.py:op.add_column('images', sa.Column('short_id', sa.String(8), nullable=True));op.create_index('ix_images_short_id', 'images', ['short_id'], unique=True). Downgrade removes index then column. Runalembic upgrade headin the api container and confirm migration applies cleanly. -
T004 [P] Add
short_idfield toImagemodel inapi/app/models.py:short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True). No change to column sizes forstorage_key(keep String(64)) orthumbnail_key(keep String(70)) — values will simply be shorter after migration. -
T005 Update
api/app/repositories/image_repo.py: (a) addasync def get_by_short_id(self, short_id: str) -> Image | None— SELECT withImage.short_id == short_idandselectinload(Image.image_tags).selectinload(ImageTag.tag); (b) addshort_id: strparameter tocreate()and persist it on theImageinstance. Write a unit test inapi/tests/unit/mocking the session to confirmget_by_short_idconstructs the correct WHERE clause. -
T005a Update
api/tests/integration/conftest.py: wherever test fixtures callimage_repo.create()or insert image rows directly, addshort_id=generate_short_id()(importgenerate_short_idfromapp.utils). This ensures all integration test fixture images have ashort_idvalue so that tests referencingimage.short_idin URLs and assertions work correctly. Runmake test-integrationand confirm existing tests still pass (no new failures introduced).
Checkpoint: Short ID infrastructure complete. The short_id column exists in DB, generate_short_id() works, and the repo can look up images by short_id.
Phase 2: User Story 1 — Clean, Shareable Image Links (Priority: P1) 🎯 MVP
Goal: All API routes accept short_id (8-char string) instead of UUID. short_id appears in every API response. The frontend navigates to /i/:short_id and the library uses short_id for navigation.
Independent Test: Upload a new image (US2 must be done for end-to-end, but US1 can be tested using a fixture image with a known short_id inserted directly). Call GET /api/v1/images/{short_id} and confirm it returns the correct image with short_id in the response. Navigate to /i/{short_id} in the browser and confirm the detail page loads.
-
T006 Write failing unit tests in
api/tests/unit/test_url_construction.py: update_make_image()mock to includeshort_id = 'AbCd1234'; add assertions that_image_to_dictresult includes"short_id": "AbCd1234". Write failing unit tests inapi/tests/unit/test_short_id.py: (1)_validate_short_id('AbCd1234')passes; (2)_validate_short_id('toolong!!')raises 422; (3)_validate_short_id('short')raises 422; (4)_validate_short_id('has space!')raises 422. Runmake test-unitand confirm new tests FAIL. -
T007 Update
api/app/routers/images.py—_image_to_dict: add"short_id": image.short_idto the returned dict (between"id"and"hash"). Add_validate_short_id(short_id: str) -> Nonehelper: compilere.compile(r'^[a-zA-Z0-9]{8}$')at module level; raiseHTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})if no match. Runmake test-unitand confirm T006 tests pass. -
T008 Update all image route handlers in
api/app/routers/images.py— change everyimage_id: uuid.UUIDpath parameter toshort_id: str; call_validate_short_id(short_id)at the start of each handler; replace allimage_repo.get_by_id(image_id)calls withimage_repo.get_by_short_id(short_id). Affected routes:GET /images/{short_id},GET /images/{short_id}/file,GET /images/{short_id}/thumbnail,PATCH /images/{short_id}/tags,DELETE /images/{short_id}. Removeimport uuidif no longer used. -
T009 [P] Write failing Angular tests: (a) in
ui/src/app/services/image.service.ts— updateMOCK_IMAGEindetail.component.spec.tsand any other spec files to includeshort_id: 'AbCd1234'; (b) inui/src/app/library/library.component.spec.ts— add test asserting that clicking an image card callsrouter.navigatewith['/i', img.short_id]rather than['/images', img.id]. Runng test --watch=falseand confirm new tests FAIL. -
T010 Update
ui/src/app/app.routes.ts: changepath: 'images/:id'topath: 'i/:id'. TheDetailComponentreadsthis.route.snapshot.paramMap.get('id')— no change needed there since the param name:idis unchanged. -
T011 Add
short_id: stringto theImageRecordinterface inui/src/app/services/image.service.ts. No changes to method signatures —get(id),updateTags(id, ...), anddelete(id)already acceptstring; callers will now passshort_idvalues instead of UUIDs. -
T011a Update
ui/src/app/detail/detail.component.ts: changethis.imageService.updateTags(this.image.id, updated)(×2, lines ~214 and ~224) andthis.imageService.delete(this.image.id)(line ~235) to usethis.image.short_idinstead ofthis.image.id. After T008 the API accepts only 8-char short_ids; passing a UUID will trigger a 422. Add assertions toui/src/app/detail/detail.component.spec.tsconfirming thatupdateTagsanddeleteare called with theshort_idvalue ('AbCd1234') not the UUID. Runng test --watch=false --include="src/app/detail/detail.component.spec.ts"and confirm new assertions pass. -
T012 Update
ui/src/app/library/library.component.ts: changerouter.navigate(['/images', img.id])(×2: click handler and keydown handler) torouter.navigate(['/i', img.short_id]). Runng test --watch=falseand confirm T009 Angular tests pass.
Checkpoint: US1 complete. API returns short_id on every image response. Routes accept short IDs. Library navigates to /i/{short_id}.
Phase 3: User Story 2 — New Uploads Assigned Short IDs (Priority: P2)
Goal: When a new image is uploaded, a short ID is generated, used as the storage key (replacing the hash), and returned in the response. Deduplication by content hash still works.
Independent Test: Upload a new image. Confirm the response includes a short_id field with exactly 8 alphanumeric characters. Confirm storage_key equals short_id and thumbnail_key equals {short_id}-thumb. Upload the same image again — confirm duplicate: true and the same short_id is returned.
-
T013 Write failing integration tests in
api/tests/integration/test_upload.py: (1) upload a new image → response includesshort_idfield matching[a-zA-Z0-9]{8}; (2)storage_keyin response equalsshort_id; (3)thumbnail_keyin response equals{short_id}-thumb(or is null for images without thumbnails); (4) upload same file twice → second response hasduplicate: trueand identicalshort_id. Runmake test-integrationand confirm new tests FAIL. -
T014 Update the upload handler in
api/app/routers/images.py: after the hash duplicate check, add collision-retry loop (up to 10 attempts):short_id = generate_short_id(); callawait storage.put(short_id, data, mime_type)instead ofawait storage.put(hash_hex, ...); callawait storage.put(f"{short_id}-thumb", ...)instead off"{hash_hex}-thumb"; passstorage_key=short_id,thumbnail_key=f"{short_id}-thumb"(or None), andshort_id=short_idtoimage_repo.create(). CatchIntegrityErroroncreate(), rollback, retry with new short_id. Importgenerate_short_idfromapp.utilsandIntegrityErrorfromsqlalchemy.exc. Runmake test-integrationand confirm T013 tests pass. -
T015 Update
ui/src/app/upload/upload.component.ts: changethis.router.navigate(['/images', res.id])tothis.router.navigate(['/i', res.short_id]). Add a test to the upload component spec (or update the existing navigation test) asserting the route usesshort_id.
Checkpoint: US2 complete. All new uploads produce a short ID and are immediately accessible at /i/{short_id}.
Phase 4: User Story 3 — All Existing Images Migrated to Short IDs (Priority: P3)
Goal: A runnable script backfills short_id for all pre-existing images, copies their storage objects to the new key pattern, and updates DB records. A final Alembic migration adds the NOT NULL constraint. After this phase the system has no UUID-keyed storage objects.
Independent Test: Run the migration script — confirm it prints a count of migrated images and exits cleanly. Run it a second time — confirm it reports 0 migrated (idempotent). Browse the library and open pre-migration images — confirm all load with short ID URLs and no broken images.
-
T016 Write unit tests in
api/tests/unit/test_migration.pycovering the migration script logic: (1) an image withshort_id IS NULLis processed (short_id generated, storage copy called, DB update called, old keys deleted); (2) an image withshort_idalready set is skipped; (3) if a storage copy fails, the error is logged and the script continues to the next image (no abort); (4) the summary at the end reports correct migrated and skipped counts. Mock the storage client and DB session. Runmake test-unitand confirm new tests FAIL (script not yet created). -
T017 Create
api/scripts/__init__.py(empty) andapi/scripts/migrate_to_short_ids.py: async main function that (a) reads DB URL and storage config from env vars viaapp.config.get_settings(); (b) creates an async DB session andS3StorageBackendinstance; (c) queries all images whereshort_id IS NULL; (d) for each: generate short_id (retry onUniqueViolation), copy storage object usingdata = await storage.get(old_key); await storage.put(new_key, data, image.mime_type)(theStorageBackendinterface provides onlyget/put/delete— there is nocopymethod), verify the copy succeeded by callingawait storage.get(new_key)and catching any exception, update the DB row (short_id,storage_key,thumbnail_key), then delete old keys withawait storage.delete(old_key); (e) skips images wherethumbnail_key IS NULLfor the thumbnail copy step; (f) wraps each image in a try/except so a single failure logs an error and continues to the next image; (g) printsMigrated: N, Skipped: M, Failed: Kon completion. Entry point:if __name__ == '__main__': asyncio.run(main()). Runmake test-unitand confirm T016 tests pass. -
T018 Create Alembic migration
api/alembic/versions/004_short_id_not_null.py:op.alter_column('images', 'short_id', nullable=False). Run this migration only after the migration script completes with 0 remaining NULL rows. Downgrade sets nullable=True. Document this ordering requirement in the migration file's docstring.
Checkpoint: US3 complete. All existing images have short IDs, storage objects use new key pattern, short_id column is NOT NULL.
Phase 5: Polish & Cross-Cutting Concerns
-
T019 Update
api/tests/integration/test_search.py,test_delete.py,test_serving.py,test_tags.py, andtest_public_access.py: wherever tests construct a URL withf"/api/v1/images/{image.id}"orf"/api/v1/images/{uuid}", replace withf"/api/v1/images/{image.short_id}". Ensure test fixtures (conftest.py) populateshort_idon images created for testing. Runmake test-integrationand confirm all integration tests pass. -
T020 Update
ui/src/app/detail/detail.component.spec.ts: addshort_id: 'AbCd1234'toMOCK_IMAGEandMOCK_IMAGE_ABSconstants. Update any test assertions that check navigation targets to useshort_id. Runng test --watch=false --include="src/app/detail/detail.component.spec.ts"and confirm all tests pass. -
T021 Run
ng lintacross all modified UI files andruff check api/app/ api/tests/ api/scripts/across all modified API files; fix any issues. Confirmruff format --check api/passes. -
T022 Run
ng build --configuration productionand confirm build succeeds with no TypeScript errors. Runmake test-unit && make test-integrationand confirm all tests pass. -
T023 Manually verify all seven quickstart.md scenarios in the browser: (1) new upload navigates to
/i/{short_id}; (2) deduplication returns same short_id; (3) library cards navigate to/i/; (4) tag and delete work via short_id; (5) pre-migration images accessible (after running script); (6) migration is idempotent; (7) "Copy URL" copies the CDN URL with short_id.
Dependencies & Execution Order
- T001 before T002 (write failing tests before implementation)
- T002 before T003/T004/T005 (utility must exist before migration and model reference it)
- T003 before T004 (DB column must exist before model references it)
- T004 before T005 (model must have
short_idfield before repo uses it) - T005 before T005a (conftest fixtures need the updated
create()signature) - T005a before T006 (integration test fixtures must have
short_idbefore any integration tests run) - T006 before T007 (write failing tests before implementation)
- T007 before T008 (helper and dict update before route param changes)
- T008 before T009/T010/T011 (API must accept short_id before frontend uses it)
- T009 before T010/T011/T011a/T012 (write failing tests before implementation)
- T010, T011, T011a, T012 can run in parallel (different files)
- T011 before T011a (interface must have
short_idfield before detail component uses it) - T013 before T014 (write failing tests before upload changes)
- T014 before T015 (upload must produce short_id before frontend navigation uses it)
- T016 before T017 (write failing tests before script)
- T017 before T018 (script must run successfully before NOT NULL migration)
- T019–T023 after T018 (polish after all implementation complete)
Execution Order Summary
Step 1: T001 → T002 (generate_short_id: tests → implementation)
Step 2: T003, T004 (parallel) (Alembic 003 + model update)
Step 3: T005 (repo: get_by_short_id + create update)
Step 3a: T005a (conftest: fixture images get short_id)
Step 4: T006 → T007 → T008 (API routes: tests → dict → route params)
Step 5: T009 (Angular failing tests)
Step 6: T010, T011, T011a, T012 (parallel) (route, interface, detail caller fix, library navigation)
Step 7: T013 → T014 → T015 (upload: tests → handler → upload navigation)
Step 8: T016 → T017 → T018 (migration: tests → script → NOT NULL migration)
Step 9: T019, T020 (parallel) (test updates)
Step 10: T021, T022, T023 (lint, build, manual verification)
Implementation Strategy
MVP (US1 + US2 — full feature with new uploads)
- T001–T005a — foundational infrastructure + conftest fixtures
- T006–T012 (including T011a) — API routes accept short_id + frontend uses
/i/ - T013–T015 — new uploads generate short_id
- STOP and VALIDATE: upload a new image, confirm
/i/{short_id}URL, confirm browsing works - T016–T018 — migrate existing images
- T019–T023 — polish
Note on Priority vs Implementation Order
US1 (P1) and US2 (P2) are implemented together before US3 (P3). The foundational phase is the true prerequisite for all three. US1 and US2 are tightly coupled in practice (you need uploads to produce short IDs before routing can be tested end-to-end), so they are sequenced rather than strictly priority-ordered.