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>
163 lines
17 KiB
Markdown
163 lines
17 KiB
Markdown
# 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.
|
||
|
||
- [X] T001 Write failing unit tests for `generate_short_id()` in `api/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 from `app.utils`. Run `make test-unit` and confirm new tests FAIL.
|
||
|
||
- [X] T002 Add `generate_short_id()` to `api/app/utils.py`: import `secrets` and `string`; define `BASE62 = string.ascii_letters + string.digits`; implement `def generate_short_id(length: int = 8) -> str: return ''.join(secrets.choice(BASE62) for _ in range(length))`. Run `make test-unit` and confirm T001 tests pass.
|
||
|
||
- [X] 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. Run `alembic upgrade head` in the api container and confirm migration applies cleanly.
|
||
|
||
- [X] T004 [P] Add `short_id` field to `Image` model in `api/app/models.py`: `short_id: Mapped[str | None] = mapped_column(String(8), unique=True, nullable=True, index=True)`. No change to column sizes for `storage_key` (keep String(64)) or `thumbnail_key` (keep String(70)) — values will simply be shorter after migration.
|
||
|
||
- [X] T005 Update `api/app/repositories/image_repo.py`: (a) add `async def get_by_short_id(self, short_id: str) -> Image | None` — SELECT with `Image.short_id == short_id` and `selectinload(Image.image_tags).selectinload(ImageTag.tag)`; (b) add `short_id: str` parameter to `create()` and persist it on the `Image` instance. Write a unit test in `api/tests/unit/` mocking the session to confirm `get_by_short_id` constructs the correct WHERE clause.
|
||
|
||
- [X] T005a Update `api/tests/integration/conftest.py`: wherever test fixtures call `image_repo.create()` or insert image rows directly, add `short_id=generate_short_id()` (import `generate_short_id` from `app.utils`). This ensures all integration test fixture images have a `short_id` value so that tests referencing `image.short_id` in URLs and assertions work correctly. Run `make test-integration` and 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.
|
||
|
||
- [X] T006 Write failing unit tests in `api/tests/unit/test_url_construction.py`: update `_make_image()` mock to include `short_id = 'AbCd1234'`; add assertions that `_image_to_dict` result includes `"short_id": "AbCd1234"`. Write failing unit tests in `api/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. Run `make test-unit` and confirm new tests FAIL.
|
||
|
||
- [X] T007 Update `api/app/routers/images.py` — `_image_to_dict`: add `"short_id": image.short_id` to the returned dict (between `"id"` and `"hash"`). Add `_validate_short_id(short_id: str) -> None` helper: compile `re.compile(r'^[a-zA-Z0-9]{8}$')` at module level; raise `HTTPException(422, detail={"detail": "Invalid image ID", "code": "invalid_short_id"})` if no match. Run `make test-unit` and confirm T006 tests pass.
|
||
|
||
- [X] T008 Update all image route handlers in `api/app/routers/images.py` — change every `image_id: uuid.UUID` path parameter to `short_id: str`; call `_validate_short_id(short_id)` at the start of each handler; replace all `image_repo.get_by_id(image_id)` calls with `image_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}`. Remove `import uuid` if no longer used.
|
||
|
||
- [X] T009 [P] Write failing Angular tests: (a) in `ui/src/app/services/image.service.ts` — update `MOCK_IMAGE` in `detail.component.spec.ts` and any other spec files to include `short_id: 'AbCd1234'`; (b) in `ui/src/app/library/library.component.spec.ts` — add test asserting that clicking an image card calls `router.navigate` with `['/i', img.short_id]` rather than `['/images', img.id]`. Run `ng test --watch=false` and confirm new tests FAIL.
|
||
|
||
- [X] T010 Update `ui/src/app/app.routes.ts`: change `path: 'images/:id'` to `path: 'i/:id'`. The `DetailComponent` reads `this.route.snapshot.paramMap.get('id')` — no change needed there since the param name `:id` is unchanged.
|
||
|
||
- [X] T011 Add `short_id: string` to the `ImageRecord` interface in `ui/src/app/services/image.service.ts`. No changes to method signatures — `get(id)`, `updateTags(id, ...)`, and `delete(id)` already accept `string`; callers will now pass `short_id` values instead of UUIDs.
|
||
|
||
- [X] T011a Update `ui/src/app/detail/detail.component.ts`: change `this.imageService.updateTags(this.image.id, updated)` (×2, lines ~214 and ~224) and `this.imageService.delete(this.image.id)` (line ~235) to use `this.image.short_id` instead of `this.image.id`. After T008 the API accepts only 8-char short_ids; passing a UUID will trigger a 422. Add assertions to `ui/src/app/detail/detail.component.spec.ts` confirming that `updateTags` and `delete` are called with the `short_id` value (`'AbCd1234'`) not the UUID. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm new assertions pass.
|
||
|
||
- [X] T012 Update `ui/src/app/library/library.component.ts`: change `router.navigate(['/images', img.id])` (×2: click handler and keydown handler) to `router.navigate(['/i', img.short_id])`. Run `ng test --watch=false` and 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.
|
||
|
||
- [X] T013 Write failing integration tests in `api/tests/integration/test_upload.py`: (1) upload a new image → response includes `short_id` field matching `[a-zA-Z0-9]{8}`; (2) `storage_key` in response equals `short_id`; (3) `thumbnail_key` in response equals `{short_id}-thumb` (or is null for images without thumbnails); (4) upload same file twice → second response has `duplicate: true` and identical `short_id`. Run `make test-integration` and confirm new tests FAIL.
|
||
|
||
- [X] 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()`; call `await storage.put(short_id, data, mime_type)` instead of `await storage.put(hash_hex, ...)`; call `await storage.put(f"{short_id}-thumb", ...)` instead of `f"{hash_hex}-thumb"`; pass `storage_key=short_id`, `thumbnail_key=f"{short_id}-thumb"` (or None), and `short_id=short_id` to `image_repo.create()`. Catch `IntegrityError` on `create()`, rollback, retry with new short_id. Import `generate_short_id` from `app.utils` and `IntegrityError` from `sqlalchemy.exc`. Run `make test-integration` and confirm T013 tests pass.
|
||
|
||
- [X] T015 Update `ui/src/app/upload/upload.component.ts`: change `this.router.navigate(['/images', res.id])` to `this.router.navigate(['/i', res.short_id])`. Add a test to the upload component spec (or update the existing navigation test) asserting the route uses `short_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.
|
||
|
||
- [X] T016 Write unit tests in `api/tests/unit/test_migration.py` covering the migration script logic: (1) an image with `short_id IS NULL` is processed (short_id generated, storage copy called, DB update called, old keys deleted); (2) an image with `short_id` already 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. Run `make test-unit` and confirm new tests FAIL (script not yet created).
|
||
|
||
- [X] T017 Create `api/scripts/__init__.py` (empty) and `api/scripts/migrate_to_short_ids.py`: async main function that (a) reads DB URL and storage config from env vars via `app.config.get_settings()`; (b) creates an async DB session and `S3StorageBackend` instance; (c) queries all images where `short_id IS NULL`; (d) for each: generate short_id (retry on `UniqueViolation`), copy storage object using `data = await storage.get(old_key); await storage.put(new_key, data, image.mime_type)` (the `StorageBackend` interface provides only `get`/`put`/`delete` — there is no `copy` method), verify the copy succeeded by calling `await storage.get(new_key)` and catching any exception, update the DB row (`short_id`, `storage_key`, `thumbnail_key`), then delete old keys with `await storage.delete(old_key)`; (e) skips images where `thumbnail_key IS NULL` for 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) prints `Migrated: N, Skipped: M, Failed: K` on completion. Entry point: `if __name__ == '__main__': asyncio.run(main())`. Run `make test-unit` and confirm T016 tests pass.
|
||
|
||
- [X] 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
|
||
|
||
- [X] T019 Update `api/tests/integration/test_search.py`, `test_delete.py`, `test_serving.py`, `test_tags.py`, and `test_public_access.py`: wherever tests construct a URL with `f"/api/v1/images/{image.id}"` or `f"/api/v1/images/{uuid}"`, replace with `f"/api/v1/images/{image.short_id}"`. Ensure test fixtures (conftest.py) populate `short_id` on images created for testing. Run `make test-integration` and confirm all integration tests pass.
|
||
|
||
- [X] T020 Update `ui/src/app/detail/detail.component.spec.ts`: add `short_id: 'AbCd1234'` to `MOCK_IMAGE` and `MOCK_IMAGE_ABS` constants. Update any test assertions that check navigation targets to use `short_id`. Run `ng test --watch=false --include="src/app/detail/detail.component.spec.ts"` and confirm all tests pass.
|
||
|
||
- [X] T021 Run `ng lint` across all modified UI files and `ruff check api/app/ api/tests/ api/scripts/` across all modified API files; fix any issues. Confirm `ruff format --check api/` passes.
|
||
|
||
- [X] T022 Run `ng build --configuration production` and confirm build succeeds with no TypeScript errors. Run `make test-unit && make test-integration` and 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_id` field before repo uses it)
|
||
- T005 before T005a (conftest fixtures need the updated `create()` signature)
|
||
- T005a before T006 (integration test fixtures must have `short_id` before 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_id` field 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)
|
||
|
||
1. T001–T005a — foundational infrastructure + conftest fixtures
|
||
2. T006–T012 (including T011a) — API routes accept short_id + frontend uses `/i/`
|
||
3. T013–T015 — new uploads generate short_id
|
||
4. **STOP and VALIDATE**: upload a new image, confirm `/i/{short_id}` URL, confirm browsing works
|
||
5. T016–T018 — migrate existing images
|
||
6. 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.
|