refactor: organize API around operation domains (#24)

* feat: reorganize API around operation domains

Restructure the oRPC contract from 14 resource-oriented routers to 8
domain-oriented routers for a cleaner public API surface.

- Consolidate 7 watch procedures into unified tracking.watch/unwatch
  with scope + ids input (movie, episode, season, series)
- Split dashboard across tracking (stats, history), library
  (continueWatching, upcoming), and discover (recommendations)
- Merge explore + search + discover into single discover router
- Absorb integrations into account.integrations
- Merge system.authConfig into system.publicInfo
- Collapse 6 admin setting endpoints into admin.settings.get/update
- Deduplicate platforms.list + explore.watchProviders into
  discover.platforms
- Add symmetric unwatchMovie/unwatchSeries core functions
- Rename titles.detail→get, titles.recommendations→similar,
  people.detail→get

BREAKING CHANGE: All client API paths have changed. REST paths now
mirror router structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback on API reorganization

- Fix updateRating invalidation in native app — was only invalidating
  title queries, now calls invalidateTitleQueries() to also refresh
  tracking.userInfo (drives the rating UI)
- Make unwatchMovie status revert consistent with unwatchSeries — revert
  any non-watchlist status, not just "completed"
- Add sync invariant comment on handleWatch/handleUnwatch loops
- Remove unused queryClient import in native use-title-actions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: updateStatus NOT_FOUND error + rate() invalidation in native

- updateStatus now throws NOT_FOUND when quickAddTitle returns null
  (title doesn't exist), instead of silently succeeding
- Add NOT_FOUND error to updateStatus contract definition
- Fix rate() in native title-actions.ts to call invalidateTitleQueries()
  instead of only invalidating orpc.titles.key() — mirrors the fix
  already applied to the hook-based path in d2ddc0f

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: expand mobile app docs and add Play Store badge

- Add the Google Play badge to the README alongside the App Store badge
- Expand the mobile app docs with the Android/Play Store release details
- Refresh docs site dependencies and related package versions for the updated docs stack

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 15:44:05 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3906f6021d
commit dce333e128
123 changed files with 3566 additions and 4325 deletions
+89
View File
@@ -26,7 +26,9 @@ import {
removeTitleStatus,
setTitleStatus,
unwatchEpisode,
unwatchMovie,
unwatchSeason,
unwatchSeries,
} from "../src/tracking";
beforeEach(() => {
@@ -495,6 +497,93 @@ describe("unwatchSeason", () => {
// ── rateTitleStars ──────────────────────────────────────────────────
// ── unwatchMovie ─────────────────────────────────────────────────────
describe("unwatchMovie", () => {
test("removes movie watch records and reverts completed to watchlist", () => {
insertUser();
insertTitle();
logMovieWatch("user-1", "title-1");
// Verify completed status
const before = testDb
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
.get();
expect(before?.status).toBe("completed");
unwatchMovie("user-1", "title-1");
// Watch records removed
const watches = testDb
.select()
.from(userMovieWatches)
.where(and(eq(userMovieWatches.userId, "user-1"), eq(userMovieWatches.titleId, "title-1")))
.all();
expect(watches).toHaveLength(0);
// Status reverted to watchlist
const after = testDb
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
.get();
expect(after?.status).toBe("watchlist");
});
test("no-op when movie was not watched", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "watchlist");
unwatchMovie("user-1", "title-1");
const row = testDb
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
.get();
expect(row?.status).toBe("watchlist");
});
});
// ── unwatchSeries ────────────────────────────────────────────────────
describe("unwatchSeries", () => {
test("removes all episode watches and reverts status to watchlist", () => {
const { episodeIds } = insertTvShow("tv-1");
insertUser();
logEpisodeWatch("user-1", episodeIds[0]);
logEpisodeWatch("user-1", episodeIds[1]);
const before = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(before.length).toBeGreaterThan(0);
unwatchSeries("user-1", "tv-1");
const after = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(after).toHaveLength(0);
const statusRow = testDb
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "tv-1")))
.get();
expect(statusRow?.status).toBe("watchlist");
});
});
// ── rateTitleStars ───────────────────────────────────────────────────
describe("rateTitleStars", () => {
test("sets rating", () => {
insertUser();