A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Remove PDS lists on account deletion

+2036 -608
+19 -2
apps/mobile/app/settings.tsx
··· 21 21 import { useTheme } from "@/contexts/theme"; 22 22 import { useToast } from "@/contexts/toast"; 23 23 24 + function getErrorMessage(error: unknown, fallback: string): string { 25 + if (error instanceof Error && error.message) { 26 + return error.message; 27 + } 28 + 29 + if ( 30 + error && 31 + typeof error === "object" && 32 + "message" in error && 33 + typeof error.message === "string" 34 + ) { 35 + return error.message; 36 + } 37 + 38 + return fallback; 39 + } 40 + 24 41 export default function SettingsScreen() { 25 42 const router = useRouter(); 26 43 const { showToast } = useToast(); ··· 73 90 await logout(); 74 91 router.replace("/"); 75 92 }, 76 - onError: () => { 77 - showToast("Failed to delete account", "error"); 93 + onError: (error) => { 94 + showToast(getErrorMessage(error, "Failed to delete account"), "error"); 78 95 }, 79 96 }); 80 97
+5 -4
apps/mobile/components/settings/DeleteAccountModal.tsx
··· 64 64 65 65 <View style={styles.pdsSwitchRow}> 66 66 <Text style={styles.pdsSwitchLabel}> 67 - Also delete my watch history from my PDS 67 + Also delete my OpnShelf data from my PDS 68 68 </Text> 69 69 <Switch 70 70 value={deletePDSData} ··· 76 76 {deletePDSData ? ( 77 77 <View style={styles.deleteWarningBox}> 78 78 <Text style={styles.deleteWarningText}> 79 - Your watch history will be permanently deleted from your personal 79 + Your OpnShelf data, including watch history, follows, lists, 80 + and list items, will be permanently deleted from your personal 80 81 data server. This cannot be recovered. 81 82 </Text> 82 83 </View> 83 84 ) : ( 84 85 <View style={styles.deleteInfoBox}> 85 86 <Text style={styles.deleteInfoText}> 86 - Your watch history will remain on your PDS. You can use another app 87 - or re-authorize OpnShelf later to access it. 87 + Your OpnShelf data will remain on your PDS. You can use another 88 + app or re-authorize OpnShelf later to access it. 88 89 </Text> 89 90 </View> 90 91 )}
+24 -6
apps/web/src/routes/profile.$handle.settings.tsx
··· 87 87 component: SettingsPage, 88 88 }); 89 89 90 + function getErrorMessage(error: unknown, fallback: string): string { 91 + if (error instanceof Error && error.message) { 92 + return error.message; 93 + } 94 + 95 + if ( 96 + error && 97 + typeof error === "object" && 98 + "message" in error && 99 + typeof error.message === "string" 100 + ) { 101 + return error.message; 102 + } 103 + 104 + return fallback; 105 + } 106 + 90 107 function SettingsPage() { 91 108 const router = useRouter(); 92 109 const queryClient = useQueryClient(); ··· 146 163 queryClient.removeQueries({ queryKey: authControllerMeQueryKey() }); 147 164 router.navigate({ to: "/" }); 148 165 }, 149 - onError: () => { 150 - toast.error("Failed to delete account"); 166 + onError: (error) => { 167 + toast.error(getErrorMessage(error, "Failed to delete account")); 151 168 }, 152 169 }); 153 170 ··· 419 436 htmlFor={deletePdsId} 420 437 className="md-body-medium cursor-pointer text-[var(--md-sys-color-on-surface)]" 421 438 > 422 - Also delete my watch history from my PDS 439 + Also delete my OpnShelf data from my PDS 423 440 </Label> 424 441 </div> 425 442 ··· 432 449 }} 433 450 > 434 451 <p className="md-body-medium text-[var(--md-sys-color-error)]"> 435 - Your watch history will be permanently deleted from your 436 - personal data server. This cannot be recovered. 452 + Your OpnShelf data, including watch history, follows, lists, 453 + and list items, will be permanently deleted from your personal 454 + data server. This cannot be recovered. 437 455 </p> 438 456 </div> 439 457 ) : ( ··· 444 462 }} 445 463 > 446 464 <p className="md-body-medium text-[var(--md-sys-color-on-surface-variant)]"> 447 - Your watch history will remain on your PDS. You can use 465 + Your OpnShelf data will remain on your PDS. You can use 448 466 another app or re-authorize OpnShelf later to access it. 449 467 </p> 450 468 </div>
+5
backend/lexicons.json
··· 20 20 "id": "xyz.opnshelf.listItem", 21 21 "path": "lexicons/app/opnshelf/listItem.json", 22 22 "local": true 23 + }, 24 + { 25 + "id": "xyz.opnshelf.follow", 26 + "path": "lexicons/app/opnshelf/follow.json", 27 + "local": true 23 28 } 24 29 ] 25 30 }
+8
backend/prisma/migrations/20260316103000_add_follow_pds_metadata/migration.sql
··· 1 + -- AlterTable 2 + ALTER TABLE "Follow" 3 + ADD COLUMN "rkey" TEXT, 4 + ADD COLUMN "uri" TEXT, 5 + ADD COLUMN "cid" TEXT; 6 + 7 + -- CreateIndex 8 + CREATE INDEX "Follow_followerDid_rkey_idx" ON "Follow"("followerDid", "rkey");
+4
backend/prisma/schema.prisma
··· 31 31 model Follow { 32 32 followerDid String 33 33 followingDid String 34 + rkey String? 35 + uri String? 36 + cid String? 34 37 createdAt DateTime @default(now()) 35 38 36 39 follower User @relation("UserFollowing", fields: [followerDid], references: [did], onDelete: Cascade) ··· 39 42 @@id([followerDid, followingDid]) 40 43 @@index([followerDid, createdAt]) 41 44 @@index([followingDid, createdAt]) 45 + @@index([followerDid, rkey]) 42 46 } 43 47 44 48 enum MediaType {
+4 -3
backend/src/auth/auth.service.spec.ts
··· 410 410 client_uri: "http://127.0.0.1:3001", 411 411 redirect_uris: ["http://127.0.0.1:3001/auth/callback"], 412 412 scope: 413 - "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 413 + "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem repo:xyz.opnshelf.follow rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 414 414 grant_types: ["authorization_code", "refresh_token"], 415 415 response_types: ["code"], 416 416 application_type: "native", ··· 478 478 479 479 expect(client.authorize).toHaveBeenCalledWith("user.bsky.social", { 480 480 scope: 481 - "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 481 + "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem repo:xyz.opnshelf.follow rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 482 482 }); 483 483 expect(result).toBe(mockUrl.toString()); 484 484 }); ··· 581 581 // - repo:xyz.opnshelf.movie: write movie records 582 582 // - repo:xyz.opnshelf.list: write list records 583 583 // - repo:xyz.opnshelf.listItem: write list item records 584 + // - repo:xyz.opnshelf.follow: write follow records 584 585 // - rpc:app.bsky.actor.getProfile: fetch user profiles via Bluesky AppView 585 586 expect(authServiceModule.OAUTH_SCOPE).toBe( 586 - "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 587 + "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem repo:xyz.opnshelf.follow rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 587 588 ); 588 589 }); 589 590
+1 -1
backend/src/auth/auth.service.ts
··· 12 12 const BLUESKY_PUBLIC_API = "https://public.api.bsky.app/xrpc"; 13 13 14 14 export const OAUTH_SCOPE = 15 - "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview"; 15 + "atproto repo:xyz.opnshelf.movie repo:xyz.opnshelf.episode repo:xyz.opnshelf.list repo:xyz.opnshelf.listItem repo:xyz.opnshelf.follow rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview"; 16 16 17 17 export interface OAuthAppState { 18 18 platform?: "mobile";
+2 -2
backend/src/generated/internal/class.ts
··· 20 20 "clientVersion": "7.3.0", 21 21 "engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735", 22 22 "activeProvider": "postgresql", 23 - "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../src/generated\"\n moduleFormat = \"cjs\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n did String @id\n handle String @unique\n displayName String?\n avatar String?\n timezone String @default(\"UTC\")\n timeFormat String @default(\"24h\")\n onboardingCompletedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedMovies TrackedMovie[]\n trackedEpisodes TrackedEpisode[]\n lists MovieList[]\n following Follow[] @relation(\"UserFollowing\")\n followers Follow[] @relation(\"UserFollowers\")\n\n @@index([handle])\n}\n\nmodel Follow {\n followerDid String\n followingDid String\n createdAt DateTime @default(now())\n\n follower User @relation(\"UserFollowing\", fields: [followerDid], references: [did], onDelete: Cascade)\n following User @relation(\"UserFollowers\", fields: [followingDid], references: [did], onDelete: Cascade)\n\n @@id([followerDid, followingDid])\n @@index([followerDid, createdAt])\n @@index([followingDid, createdAt])\n}\n\nenum MediaType {\n movie\n show\n}\n\n// OAuth session storage for @atproto/oauth-client-node sessionStore\n// No FK to User because the OAuth library stores session before we create the User\n// Cookie stores opaque id (not DID) for session lookup\nmodel AuthSession {\n id String @id @default(cuid())\n userDid String @unique\n sessionData String // JSON-serialized session from the library\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([updatedAt])\n}\n\nmodel AuthState {\n key String @id\n stateData String\n expiresAt DateTime\n createdAt DateTime @default(now())\n\n @@index([expiresAt])\n}\n\nmodel Movie {\n movieId String @id\n title String\n posterPath String?\n backdropPath String?\n releaseYear Int?\n releaseDate DateTime?\n overview String?\n colors Json? // { primary, secondary, accent, muted }\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedMovie[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel Show {\n showId String @id\n title String\n posterPath String?\n backdropPath String?\n firstAirYear Int?\n firstAirDate DateTime?\n overview String?\n colors Json?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedEpisode[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel TrackedMovie {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n movieId String\n movie Movie @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([movieId])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel TrackedEpisode {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n showId String\n show Show @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n seasonNumber Int\n episodeNumber Int\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([showId])\n @@index([seasonNumber])\n @@index([episodeNumber])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel MovieList {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n name String\n description String?\n slug String\n isDefault Boolean @default(false)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n items ListItem[]\n\n @@unique([userDid, slug])\n @@index([userDid])\n @@index([isDefault])\n}\n\nmodel ListItem {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n listId String\n list MovieList @relation(fields: [listId], references: [id], onDelete: Cascade)\n\n mediaType MediaType\n mediaId String\n movieId String?\n movie Movie? @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n showId String?\n show Show? @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n notes String?\n position Int @default(0)\n\n createdAt DateTime @default(now())\n\n @@unique([listId, mediaType, mediaId])\n @@index([listId])\n @@index([mediaType, mediaId])\n}\n", 23 + "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../src/generated\"\n moduleFormat = \"cjs\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n did String @id\n handle String @unique\n displayName String?\n avatar String?\n timezone String @default(\"UTC\")\n timeFormat String @default(\"24h\")\n onboardingCompletedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedMovies TrackedMovie[]\n trackedEpisodes TrackedEpisode[]\n lists MovieList[]\n following Follow[] @relation(\"UserFollowing\")\n followers Follow[] @relation(\"UserFollowers\")\n\n @@index([handle])\n}\n\nmodel Follow {\n followerDid String\n followingDid String\n rkey String?\n uri String?\n cid String?\n createdAt DateTime @default(now())\n\n follower User @relation(\"UserFollowing\", fields: [followerDid], references: [did], onDelete: Cascade)\n following User @relation(\"UserFollowers\", fields: [followingDid], references: [did], onDelete: Cascade)\n\n @@id([followerDid, followingDid])\n @@index([followerDid, createdAt])\n @@index([followingDid, createdAt])\n @@index([followerDid, rkey])\n}\n\nenum MediaType {\n movie\n show\n}\n\n// OAuth session storage for @atproto/oauth-client-node sessionStore\n// No FK to User because the OAuth library stores session before we create the User\n// Cookie stores opaque id (not DID) for session lookup\nmodel AuthSession {\n id String @id @default(cuid())\n userDid String @unique\n sessionData String // JSON-serialized session from the library\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([updatedAt])\n}\n\nmodel AuthState {\n key String @id\n stateData String\n expiresAt DateTime\n createdAt DateTime @default(now())\n\n @@index([expiresAt])\n}\n\nmodel Movie {\n movieId String @id\n title String\n posterPath String?\n backdropPath String?\n releaseYear Int?\n releaseDate DateTime?\n overview String?\n colors Json? // { primary, secondary, accent, muted }\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedMovie[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel Show {\n showId String @id\n title String\n posterPath String?\n backdropPath String?\n firstAirYear Int?\n firstAirDate DateTime?\n overview String?\n colors Json?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedEpisode[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel TrackedMovie {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n movieId String\n movie Movie @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([movieId])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel TrackedEpisode {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n showId String\n show Show @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n seasonNumber Int\n episodeNumber Int\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([showId])\n @@index([seasonNumber])\n @@index([episodeNumber])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel MovieList {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n name String\n description String?\n slug String\n isDefault Boolean @default(false)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n items ListItem[]\n\n @@unique([userDid, slug])\n @@index([userDid])\n @@index([isDefault])\n}\n\nmodel ListItem {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n listId String\n list MovieList @relation(fields: [listId], references: [id], onDelete: Cascade)\n\n mediaType MediaType\n mediaId String\n movieId String?\n movie Movie? @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n showId String?\n show Show? @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n notes String?\n position Int @default(0)\n\n createdAt DateTime @default(now())\n\n @@unique([listId, mediaType, mediaId])\n @@index([listId])\n @@index([mediaType, mediaId])\n}\n", 24 24 "runtimeDataModel": { 25 25 "models": {}, 26 26 "enums": {}, ··· 28 28 } 29 29 } 30 30 31 - config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"did\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"handle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"displayName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timezone\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timeFormat\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"onboardingCompletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedMovies\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"trackedEpisodes\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"lists\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"MovieListToUser\"},{\"name\":\"following\",\"kind\":\"object\",\"type\":\"Follow\",\"relationName\":\"UserFollowing\"},{\"name\":\"followers\",\"kind\":\"object\",\"type\":\"Follow\",\"relationName\":\"UserFollowers\"}],\"dbName\":null},\"Follow\":{\"fields\":[{\"name\":\"followerDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"followingDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"follower\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserFollowing\"},{\"name\":\"following\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserFollowers\"}],\"dbName\":null},\"AuthSession\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"AuthState\":{\"fields\":[{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stateData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Movie\":{\"fields\":[{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"releaseYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"releaseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovie\"}],\"dbName\":null},\"Show\":{\"fields\":[{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstAirYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"firstAirDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToShow\"}],\"dbName\":null},\"TrackedMovie\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"TrackedEpisode\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"seasonNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"episodeNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"MovieList\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"MovieListToUser\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"slug\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDefault\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"items\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovieList\"}],\"dbName\":null},\"ListItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"listId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"list\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"ListItemToMovieList\"},{\"name\":\"mediaType\",\"kind\":\"enum\",\"type\":\"MediaType\"},{\"name\":\"mediaId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"ListItemToMovie\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ListItemToShow\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"position\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") 31 + config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"did\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"handle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"displayName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timezone\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timeFormat\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"onboardingCompletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedMovies\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"trackedEpisodes\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"lists\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"MovieListToUser\"},{\"name\":\"following\",\"kind\":\"object\",\"type\":\"Follow\",\"relationName\":\"UserFollowing\"},{\"name\":\"followers\",\"kind\":\"object\",\"type\":\"Follow\",\"relationName\":\"UserFollowers\"}],\"dbName\":null},\"Follow\":{\"fields\":[{\"name\":\"followerDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"followingDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"follower\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserFollowing\"},{\"name\":\"following\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserFollowers\"}],\"dbName\":null},\"AuthSession\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"AuthState\":{\"fields\":[{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stateData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Movie\":{\"fields\":[{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"releaseYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"releaseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovie\"}],\"dbName\":null},\"Show\":{\"fields\":[{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstAirYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"firstAirDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToShow\"}],\"dbName\":null},\"TrackedMovie\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"TrackedEpisode\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"seasonNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"episodeNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"MovieList\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"MovieListToUser\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"slug\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDefault\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"items\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovieList\"}],\"dbName\":null},\"ListItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"listId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"list\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"ListItemToMovieList\"},{\"name\":\"mediaType\",\"kind\":\"enum\",\"type\":\"MediaType\"},{\"name\":\"mediaId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"ListItemToMovie\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ListItemToShow\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"position\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") 32 32 33 33 async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> { 34 34 const { Buffer } = await import('node:buffer')
+3
backend/src/generated/internal/prismaNamespace.ts
··· 1210 1210 export const FollowScalarFieldEnum = { 1211 1211 followerDid: 'followerDid', 1212 1212 followingDid: 'followingDid', 1213 + rkey: 'rkey', 1214 + uri: 'uri', 1215 + cid: 'cid', 1213 1216 createdAt: 'createdAt' 1214 1217 } as const 1215 1218
+3
backend/src/generated/internal/prismaNamespaceBrowser.ts
··· 97 97 export const FollowScalarFieldEnum = { 98 98 followerDid: 'followerDid', 99 99 followingDid: 'followingDid', 100 + rkey: 'rkey', 101 + uri: 'uri', 102 + cid: 'cid', 100 103 createdAt: 'createdAt' 101 104 } as const 102 105
+124 -1
backend/src/generated/models/Follow.ts
··· 27 27 export type FollowMinAggregateOutputType = { 28 28 followerDid: string | null 29 29 followingDid: string | null 30 + rkey: string | null 31 + uri: string | null 32 + cid: string | null 30 33 createdAt: Date | null 31 34 } 32 35 33 36 export type FollowMaxAggregateOutputType = { 34 37 followerDid: string | null 35 38 followingDid: string | null 39 + rkey: string | null 40 + uri: string | null 41 + cid: string | null 36 42 createdAt: Date | null 37 43 } 38 44 39 45 export type FollowCountAggregateOutputType = { 40 46 followerDid: number 41 47 followingDid: number 48 + rkey: number 49 + uri: number 50 + cid: number 42 51 createdAt: number 43 52 _all: number 44 53 } ··· 47 56 export type FollowMinAggregateInputType = { 48 57 followerDid?: true 49 58 followingDid?: true 59 + rkey?: true 60 + uri?: true 61 + cid?: true 50 62 createdAt?: true 51 63 } 52 64 53 65 export type FollowMaxAggregateInputType = { 54 66 followerDid?: true 55 67 followingDid?: true 68 + rkey?: true 69 + uri?: true 70 + cid?: true 56 71 createdAt?: true 57 72 } 58 73 59 74 export type FollowCountAggregateInputType = { 60 75 followerDid?: true 61 76 followingDid?: true 77 + rkey?: true 78 + uri?: true 79 + cid?: true 62 80 createdAt?: true 63 81 _all?: true 64 82 } ··· 138 156 export type FollowGroupByOutputType = { 139 157 followerDid: string 140 158 followingDid: string 159 + rkey: string | null 160 + uri: string | null 161 + cid: string | null 141 162 createdAt: Date 142 163 _count: FollowCountAggregateOutputType | null 143 164 _min: FollowMinAggregateOutputType | null ··· 165 186 NOT?: Prisma.FollowWhereInput | Prisma.FollowWhereInput[] 166 187 followerDid?: Prisma.StringFilter<"Follow"> | string 167 188 followingDid?: Prisma.StringFilter<"Follow"> | string 189 + rkey?: Prisma.StringNullableFilter<"Follow"> | string | null 190 + uri?: Prisma.StringNullableFilter<"Follow"> | string | null 191 + cid?: Prisma.StringNullableFilter<"Follow"> | string | null 168 192 createdAt?: Prisma.DateTimeFilter<"Follow"> | Date | string 169 193 follower?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> 170 194 following?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> ··· 173 197 export type FollowOrderByWithRelationInput = { 174 198 followerDid?: Prisma.SortOrder 175 199 followingDid?: Prisma.SortOrder 200 + rkey?: Prisma.SortOrderInput | Prisma.SortOrder 201 + uri?: Prisma.SortOrderInput | Prisma.SortOrder 202 + cid?: Prisma.SortOrderInput | Prisma.SortOrder 176 203 createdAt?: Prisma.SortOrder 177 204 follower?: Prisma.UserOrderByWithRelationInput 178 205 following?: Prisma.UserOrderByWithRelationInput ··· 185 212 NOT?: Prisma.FollowWhereInput | Prisma.FollowWhereInput[] 186 213 followerDid?: Prisma.StringFilter<"Follow"> | string 187 214 followingDid?: Prisma.StringFilter<"Follow"> | string 215 + rkey?: Prisma.StringNullableFilter<"Follow"> | string | null 216 + uri?: Prisma.StringNullableFilter<"Follow"> | string | null 217 + cid?: Prisma.StringNullableFilter<"Follow"> | string | null 188 218 createdAt?: Prisma.DateTimeFilter<"Follow"> | Date | string 189 219 follower?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> 190 220 following?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput> ··· 193 223 export type FollowOrderByWithAggregationInput = { 194 224 followerDid?: Prisma.SortOrder 195 225 followingDid?: Prisma.SortOrder 226 + rkey?: Prisma.SortOrderInput | Prisma.SortOrder 227 + uri?: Prisma.SortOrderInput | Prisma.SortOrder 228 + cid?: Prisma.SortOrderInput | Prisma.SortOrder 196 229 createdAt?: Prisma.SortOrder 197 230 _count?: Prisma.FollowCountOrderByAggregateInput 198 231 _max?: Prisma.FollowMaxOrderByAggregateInput ··· 205 238 NOT?: Prisma.FollowScalarWhereWithAggregatesInput | Prisma.FollowScalarWhereWithAggregatesInput[] 206 239 followerDid?: Prisma.StringWithAggregatesFilter<"Follow"> | string 207 240 followingDid?: Prisma.StringWithAggregatesFilter<"Follow"> | string 241 + rkey?: Prisma.StringNullableWithAggregatesFilter<"Follow"> | string | null 242 + uri?: Prisma.StringNullableWithAggregatesFilter<"Follow"> | string | null 243 + cid?: Prisma.StringNullableWithAggregatesFilter<"Follow"> | string | null 208 244 createdAt?: Prisma.DateTimeWithAggregatesFilter<"Follow"> | Date | string 209 245 } 210 246 211 247 export type FollowCreateInput = { 248 + rkey?: string | null 249 + uri?: string | null 250 + cid?: string | null 212 251 createdAt?: Date | string 213 252 follower: Prisma.UserCreateNestedOneWithoutFollowingInput 214 253 following: Prisma.UserCreateNestedOneWithoutFollowersInput ··· 217 256 export type FollowUncheckedCreateInput = { 218 257 followerDid: string 219 258 followingDid: string 259 + rkey?: string | null 260 + uri?: string | null 261 + cid?: string | null 220 262 createdAt?: Date | string 221 263 } 222 264 223 265 export type FollowUpdateInput = { 266 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 267 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 268 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 224 269 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 225 270 follower?: Prisma.UserUpdateOneRequiredWithoutFollowingNestedInput 226 271 following?: Prisma.UserUpdateOneRequiredWithoutFollowersNestedInput ··· 229 274 export type FollowUncheckedUpdateInput = { 230 275 followerDid?: Prisma.StringFieldUpdateOperationsInput | string 231 276 followingDid?: Prisma.StringFieldUpdateOperationsInput | string 277 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 278 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 279 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 232 280 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 233 281 } 234 282 235 283 export type FollowCreateManyInput = { 236 284 followerDid: string 237 285 followingDid: string 286 + rkey?: string | null 287 + uri?: string | null 288 + cid?: string | null 238 289 createdAt?: Date | string 239 290 } 240 291 241 292 export type FollowUpdateManyMutationInput = { 293 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 294 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 295 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 242 296 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 243 297 } 244 298 245 299 export type FollowUncheckedUpdateManyInput = { 246 300 followerDid?: Prisma.StringFieldUpdateOperationsInput | string 247 301 followingDid?: Prisma.StringFieldUpdateOperationsInput | string 302 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 303 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 304 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 248 305 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 249 306 } 250 307 ··· 266 323 export type FollowCountOrderByAggregateInput = { 267 324 followerDid?: Prisma.SortOrder 268 325 followingDid?: Prisma.SortOrder 326 + rkey?: Prisma.SortOrder 327 + uri?: Prisma.SortOrder 328 + cid?: Prisma.SortOrder 269 329 createdAt?: Prisma.SortOrder 270 330 } 271 331 272 332 export type FollowMaxOrderByAggregateInput = { 273 333 followerDid?: Prisma.SortOrder 274 334 followingDid?: Prisma.SortOrder 335 + rkey?: Prisma.SortOrder 336 + uri?: Prisma.SortOrder 337 + cid?: Prisma.SortOrder 275 338 createdAt?: Prisma.SortOrder 276 339 } 277 340 278 341 export type FollowMinOrderByAggregateInput = { 279 342 followerDid?: Prisma.SortOrder 280 343 followingDid?: Prisma.SortOrder 344 + rkey?: Prisma.SortOrder 345 + uri?: Prisma.SortOrder 346 + cid?: Prisma.SortOrder 281 347 createdAt?: Prisma.SortOrder 282 348 } 283 349 ··· 366 432 } 367 433 368 434 export type FollowCreateWithoutFollowerInput = { 435 + rkey?: string | null 436 + uri?: string | null 437 + cid?: string | null 369 438 createdAt?: Date | string 370 439 following: Prisma.UserCreateNestedOneWithoutFollowersInput 371 440 } 372 441 373 442 export type FollowUncheckedCreateWithoutFollowerInput = { 374 443 followingDid: string 444 + rkey?: string | null 445 + uri?: string | null 446 + cid?: string | null 375 447 createdAt?: Date | string 376 448 } 377 449 ··· 386 458 } 387 459 388 460 export type FollowCreateWithoutFollowingInput = { 461 + rkey?: string | null 462 + uri?: string | null 463 + cid?: string | null 389 464 createdAt?: Date | string 390 465 follower: Prisma.UserCreateNestedOneWithoutFollowingInput 391 466 } 392 467 393 468 export type FollowUncheckedCreateWithoutFollowingInput = { 394 469 followerDid: string 470 + rkey?: string | null 471 + uri?: string | null 472 + cid?: string | null 395 473 createdAt?: Date | string 396 474 } 397 475 ··· 427 505 NOT?: Prisma.FollowScalarWhereInput | Prisma.FollowScalarWhereInput[] 428 506 followerDid?: Prisma.StringFilter<"Follow"> | string 429 507 followingDid?: Prisma.StringFilter<"Follow"> | string 508 + rkey?: Prisma.StringNullableFilter<"Follow"> | string | null 509 + uri?: Prisma.StringNullableFilter<"Follow"> | string | null 510 + cid?: Prisma.StringNullableFilter<"Follow"> | string | null 430 511 createdAt?: Prisma.DateTimeFilter<"Follow"> | Date | string 431 512 } 432 513 ··· 448 529 449 530 export type FollowCreateManyFollowerInput = { 450 531 followingDid: string 532 + rkey?: string | null 533 + uri?: string | null 534 + cid?: string | null 451 535 createdAt?: Date | string 452 536 } 453 537 454 538 export type FollowCreateManyFollowingInput = { 455 539 followerDid: string 540 + rkey?: string | null 541 + uri?: string | null 542 + cid?: string | null 456 543 createdAt?: Date | string 457 544 } 458 545 459 546 export type FollowUpdateWithoutFollowerInput = { 547 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 548 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 549 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 460 550 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 461 551 following?: Prisma.UserUpdateOneRequiredWithoutFollowersNestedInput 462 552 } 463 553 464 554 export type FollowUncheckedUpdateWithoutFollowerInput = { 465 555 followingDid?: Prisma.StringFieldUpdateOperationsInput | string 556 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 557 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 558 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 466 559 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 467 560 } 468 561 469 562 export type FollowUncheckedUpdateManyWithoutFollowerInput = { 470 563 followingDid?: Prisma.StringFieldUpdateOperationsInput | string 564 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 565 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 566 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 471 567 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 472 568 } 473 569 474 570 export type FollowUpdateWithoutFollowingInput = { 571 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 572 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 573 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 475 574 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 476 575 follower?: Prisma.UserUpdateOneRequiredWithoutFollowingNestedInput 477 576 } 478 577 479 578 export type FollowUncheckedUpdateWithoutFollowingInput = { 480 579 followerDid?: Prisma.StringFieldUpdateOperationsInput | string 580 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 581 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 582 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 481 583 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 482 584 } 483 585 484 586 export type FollowUncheckedUpdateManyWithoutFollowingInput = { 485 587 followerDid?: Prisma.StringFieldUpdateOperationsInput | string 588 + rkey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 589 + uri?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 590 + cid?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 486 591 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 487 592 } 488 593 ··· 491 596 export type FollowSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ 492 597 followerDid?: boolean 493 598 followingDid?: boolean 599 + rkey?: boolean 600 + uri?: boolean 601 + cid?: boolean 494 602 createdAt?: boolean 495 603 follower?: boolean | Prisma.UserDefaultArgs<ExtArgs> 496 604 following?: boolean | Prisma.UserDefaultArgs<ExtArgs> ··· 499 607 export type FollowSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ 500 608 followerDid?: boolean 501 609 followingDid?: boolean 610 + rkey?: boolean 611 + uri?: boolean 612 + cid?: boolean 502 613 createdAt?: boolean 503 614 follower?: boolean | Prisma.UserDefaultArgs<ExtArgs> 504 615 following?: boolean | Prisma.UserDefaultArgs<ExtArgs> ··· 507 618 export type FollowSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ 508 619 followerDid?: boolean 509 620 followingDid?: boolean 621 + rkey?: boolean 622 + uri?: boolean 623 + cid?: boolean 510 624 createdAt?: boolean 511 625 follower?: boolean | Prisma.UserDefaultArgs<ExtArgs> 512 626 following?: boolean | Prisma.UserDefaultArgs<ExtArgs> ··· 515 629 export type FollowSelectScalar = { 516 630 followerDid?: boolean 517 631 followingDid?: boolean 632 + rkey?: boolean 633 + uri?: boolean 634 + cid?: boolean 518 635 createdAt?: boolean 519 636 } 520 637 521 - export type FollowOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"followerDid" | "followingDid" | "createdAt", ExtArgs["result"]["follow"]> 638 + export type FollowOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"followerDid" | "followingDid" | "rkey" | "uri" | "cid" | "createdAt", ExtArgs["result"]["follow"]> 522 639 export type FollowInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { 523 640 follower?: boolean | Prisma.UserDefaultArgs<ExtArgs> 524 641 following?: boolean | Prisma.UserDefaultArgs<ExtArgs> ··· 541 658 scalars: runtime.Types.Extensions.GetPayloadResult<{ 542 659 followerDid: string 543 660 followingDid: string 661 + rkey: string | null 662 + uri: string | null 663 + cid: string | null 544 664 createdAt: Date 545 665 }, ExtArgs["result"]["follow"]> 546 666 composites: {} ··· 969 1089 export interface FollowFieldRefs { 970 1090 readonly followerDid: Prisma.FieldRef<"Follow", 'String'> 971 1091 readonly followingDid: Prisma.FieldRef<"Follow", 'String'> 1092 + readonly rkey: Prisma.FieldRef<"Follow", 'String'> 1093 + readonly uri: Prisma.FieldRef<"Follow", 'String'> 1094 + readonly cid: Prisma.FieldRef<"Follow", 'String'> 972 1095 readonly createdAt: Prisma.FieldRef<"Follow", 'DateTime'> 973 1096 } 974 1097
+2
backend/src/ingester/ingester.module.ts
··· 2 2 import { ListsModule } from "../lists/lists.module"; 3 3 import { MoviesModule } from "../movies/movies.module"; 4 4 import { PrismaModule } from "../prisma/prisma.module"; 5 + import { SocialModule } from "../social/social.module"; 5 6 import { ShowsModule } from "../shows/shows.module"; 6 7 import { IngesterService } from "./ingester.service"; 7 8 ··· 11 12 forwardRef(() => MoviesModule), 12 13 forwardRef(() => ShowsModule), 13 14 forwardRef(() => ListsModule), 15 + forwardRef(() => SocialModule), 14 16 ], 15 17 providers: [IngesterService], 16 18 exports: [IngesterService],
+66
backend/src/ingester/ingester.service.spec.ts
··· 37 37 import { ListsService } from "../lists/lists.service"; 38 38 import { MoviesService } from "../movies/movies.service"; 39 39 import { PrismaService } from "../prisma/prisma.service"; 40 + import { SocialService } from "../social/social.service"; 40 41 import { ShowsService } from "../shows/shows.service"; 41 42 import { IngesterService } from "./ingester.service"; 42 43 ··· 73 74 deleteListRecord: jest.Mock; 74 75 indexListItemRecord: jest.Mock; 75 76 deleteListItemRecord: jest.Mock; 77 + }; 78 + let mockSocialService: { 79 + indexFollowRecord: jest.Mock; 80 + deleteFollowRecordIndex: jest.Mock; 76 81 }; 77 82 78 83 const mockConfigService = { ··· 119 124 deleteListItemRecord: jest.fn(), 120 125 }; 121 126 127 + mockSocialService = { 128 + indexFollowRecord: jest.fn(), 129 + deleteFollowRecordIndex: jest.fn(), 130 + }; 131 + 122 132 const module: TestingModule = await Test.createTestingModule({ 123 133 providers: [ 124 134 IngesterService, ··· 127 137 { provide: MoviesService, useValue: mockMoviesService }, 128 138 { provide: ShowsService, useValue: mockShowsService }, 129 139 { provide: ListsService, useValue: mockListsService }, 140 + { provide: SocialService, useValue: mockSocialService }, 130 141 ], 131 142 }).compile(); 132 143 ··· 182 193 } 183 194 return recordHandler; 184 195 }; 196 + 197 + it("should index follows for xyz.opnshelf.follow create", async () => { 198 + const recordHandler = setupRecordHandler(); 199 + mockPrismaService.user.findUnique.mockResolvedValue({ 200 + did: "did:plc:abc123", 201 + }); 202 + 203 + await recordHandler({ 204 + id: 4, 205 + type: "record", 206 + action: "create", 207 + did: "did:plc:abc123", 208 + rev: "rev-follow-1", 209 + collection: "xyz.opnshelf.follow", 210 + rkey: "follow-rkey-1", 211 + record: { 212 + $type: "xyz.opnshelf.follow", 213 + subjectDid: "did:plc:friend-1", 214 + createdAt: "2026-03-16T10:00:00.000Z", 215 + }, 216 + cid: "cid-follow-1", 217 + live: true, 218 + }); 219 + 220 + expect(mockSocialService.indexFollowRecord).toHaveBeenCalledWith( 221 + "did:plc:abc123", 222 + "follow-rkey-1", 223 + "cid-follow-1", 224 + expect.objectContaining({ 225 + subjectDid: "did:plc:friend-1", 226 + }), 227 + "at://did:plc:abc123/xyz.opnshelf.follow/follow-rkey-1", 228 + ); 229 + }); 230 + 231 + it("should delete follows for xyz.opnshelf.follow delete", async () => { 232 + const recordHandler = setupRecordHandler(); 233 + 234 + await recordHandler({ 235 + id: 5, 236 + type: "record", 237 + action: "delete", 238 + did: "did:plc:abc123", 239 + rev: "rev-follow-2", 240 + collection: "xyz.opnshelf.follow", 241 + rkey: "follow-rkey-1", 242 + cid: "cid-follow-1", 243 + live: true, 244 + }); 245 + 246 + expect(mockSocialService.deleteFollowRecordIndex).toHaveBeenCalledWith( 247 + "did:plc:abc123", 248 + "follow-rkey-1", 249 + ); 250 + }); 185 251 186 252 it("should upsert tracked movie for xyz.opnshelf.movie create", async () => { 187 253 const recordHandler = setupRecordHandler();
+45
backend/src/ingester/ingester.service.ts
··· 12 12 } from "@nestjs/common"; 13 13 import { ConfigService } from "@nestjs/config"; 14 14 import { 15 + $nsid as FOLLOW_COLLECTION, 16 + main as followSchema, 17 + } from "../lexicons/xyz/opnshelf/follow"; 18 + import type { Main as FollowRecord } from "../lexicons/xyz/opnshelf/follow.defs"; 19 + import { 15 20 $nsid as LIST_COLLECTION, 16 21 main as listSchema, 17 22 } from "../lexicons/xyz/opnshelf/list"; ··· 34 39 import { ListsService } from "../lists/lists.service"; 35 40 import { MoviesService } from "../movies/movies.service"; 36 41 import { PrismaService } from "../prisma/prisma.service"; 42 + import { SocialService } from "../social/social.service"; 37 43 import { ShowsService } from "../shows/shows.service"; 38 44 39 45 @Injectable() ··· 50 56 private readonly moviesService: MoviesService, 51 57 private readonly showsService: ShowsService, 52 58 private readonly listsService: ListsService, 59 + private readonly socialService: SocialService, 53 60 ) { 54 61 this.tapUrl = this.config.get<string>("TAP_URL") ?? "http://localhost:2480"; 55 62 this.tapAdminPassword = this.config.get<string>("TAP_ADMIN_PASSWORD"); ··· 203 210 await this.handleMovieEvent(evt, uri); 204 211 } else if (evt.collection === EPISODE_COLLECTION) { 205 212 await this.handleEpisodeEvent(evt, uri); 213 + } else if (evt.collection === FOLLOW_COLLECTION) { 214 + await this.handleFollowEvent(evt, uri); 206 215 } else if (evt.collection === LIST_COLLECTION) { 207 216 await this.handleListEvent(evt, uri); 208 217 } else if (evt.collection === LIST_ITEM_COLLECTION) { 209 218 await this.handleListItemEvent(evt, uri); 210 219 } else { 211 220 this.logger.debug(`Skipping event for collection ${evt.collection}`); 221 + } 222 + } 223 + 224 + private async handleFollowEvent(evt: RecordEvent, uri: string) { 225 + if (evt.action === "create" || evt.action === "update") { 226 + if (!evt.record) { 227 + this.logger.warn(`Record event missing record data: ${uri}`); 228 + return; 229 + } 230 + 231 + let followRecord: FollowRecord; 232 + try { 233 + followRecord = followSchema.parse(evt.record); 234 + } catch { 235 + this.logger.debug("Received invalid follow record, skipping"); 236 + return; 237 + } 238 + 239 + const user = await this.prisma.user.findUnique({ 240 + where: { did: evt.did }, 241 + }); 242 + 243 + if (!user) { 244 + this.logger.debug(`User ${evt.did} not in database, skipping record`); 245 + return; 246 + } 247 + 248 + await this.socialService.indexFollowRecord( 249 + evt.did, 250 + evt.rkey, 251 + evt.cid, 252 + followRecord, 253 + uri, 254 + ); 255 + } else if (evt.action === "delete") { 256 + await this.socialService.deleteFollowRecordIndex(evt.did, evt.rkey); 212 257 } 213 258 } 214 259
+1
backend/src/lexicons/xyz/opnshelf.ts
··· 3 3 */ 4 4 5 5 export * as episode from './opnshelf/episode.js' 6 + export * as follow from './opnshelf/follow.js' 6 7 export * as list from './opnshelf/list.js' 7 8 export * as listItem from './opnshelf/listItem.js' 8 9 export * as movie from './opnshelf/movie.js'
+51
backend/src/lexicons/xyz/opnshelf/follow.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + 7 + const $nsid = 'xyz.opnshelf.follow' 8 + 9 + export { $nsid } 10 + 11 + /** An OpnShelf follow relationship stored on a user's PDS */ 12 + type Main = { 13 + $type: 'xyz.opnshelf.follow' 14 + 15 + /** 16 + * The DID of the OpnShelf user being followed 17 + */ 18 + subjectDid: string 19 + 20 + /** 21 + * When the follow record was created 22 + */ 23 + createdAt: l.DatetimeString 24 + } 25 + 26 + export type { Main } 27 + 28 + /** An OpnShelf follow relationship stored on a user's PDS */ 29 + const main = l.record<'tid', Main>( 30 + 'tid', 31 + $nsid, 32 + l.object({ 33 + subjectDid: l.string(), 34 + createdAt: l.string({ format: 'datetime' }), 35 + }), 36 + ) 37 + 38 + export { main } 39 + 40 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main), 41 + $build = /*#__PURE__*/ main.build.bind(main), 42 + $type = /*#__PURE__*/ main.$type 43 + export const $assert = /*#__PURE__*/ main.assert.bind(main), 44 + $check = /*#__PURE__*/ main.check.bind(main), 45 + $cast = /*#__PURE__*/ main.cast.bind(main), 46 + $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main), 47 + $matches = /*#__PURE__*/ main.matches.bind(main), 48 + $parse = /*#__PURE__*/ main.parse.bind(main), 49 + $safeParse = /*#__PURE__*/ main.safeParse.bind(main), 50 + $validate = /*#__PURE__*/ main.validate.bind(main), 51 + $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
backend/src/lexicons/xyz/opnshelf/follow.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './follow.defs.js' 6 + export * as $defs from './follow.defs.js'
+175
backend/src/lists/lists.service.spec.ts
··· 6 6 7 7 const mockPutRecord = jest.fn(); 8 8 const mockDeleteRecord = jest.fn(); 9 + const mockListRecords = jest.fn(); 9 10 jest.mock("@atproto/api", () => ({ 10 11 Agent: jest.fn().mockImplementation(() => ({ 11 12 com: { ··· 13 14 repo: { 14 15 putRecord: mockPutRecord, 15 16 deleteRecord: mockDeleteRecord, 17 + listRecords: mockListRecords, 16 18 }, 17 19 }, 18 20 }, ··· 31 33 $type: "xyz.opnshelf.list", 32 34 ...data, 33 35 })), 36 + parse: jest.fn((data: Record<string, unknown>) => data), 34 37 }, 35 38 $nsid: "xyz.opnshelf.list", 36 39 })); ··· 90 93 jest.clearAllMocks(); 91 94 mockPutRecord.mockReset(); 92 95 mockDeleteRecord.mockReset(); 96 + mockListRecords.mockReset(); 93 97 94 98 const module: TestingModule = await Test.createTestingModule({ 95 99 providers: [ ··· 101 105 }).compile(); 102 106 103 107 service = module.get<ListsService>(ListsService); 108 + }); 109 + 110 + describe("ensureDefaultLists", () => { 111 + it("indexes repo-backed default lists before creating missing defaults", async () => { 112 + mockPrismaService.movieList.findMany 113 + .mockResolvedValueOnce([]) 114 + .mockResolvedValueOnce([ 115 + { 116 + id: "list-watchlist", 117 + rkey: "watchlist-rkey", 118 + uri: "at://did:plc:abc123/xyz.opnshelf.list/watchlist-rkey", 119 + cid: "cid-watchlist", 120 + userDid: "did:plc:abc123", 121 + name: "Watchlist", 122 + description: "Items you want to watch", 123 + slug: "watchlist", 124 + isDefault: true, 125 + createdAt: new Date("2024-01-01"), 126 + updatedAt: new Date("2024-01-01"), 127 + }, 128 + { 129 + id: "list-favorites", 130 + rkey: "favorites-rkey", 131 + uri: "at://did:plc:abc123/xyz.opnshelf.list/favorites-rkey", 132 + cid: "cid-favorites", 133 + userDid: "did:plc:abc123", 134 + name: "Favorites", 135 + description: "Your favorite items", 136 + slug: "favorites", 137 + isDefault: true, 138 + createdAt: new Date("2024-01-02"), 139 + updatedAt: new Date("2024-01-02"), 140 + }, 141 + ]); 142 + mockListRecords.mockResolvedValue({ 143 + data: { 144 + records: [ 145 + { 146 + uri: "at://did:plc:abc123/xyz.opnshelf.list/favorites-rkey", 147 + cid: "cid-favorites", 148 + value: { 149 + name: "Favorites", 150 + description: "Your favorite items", 151 + slug: "favorites", 152 + isDefault: true, 153 + createdAt: "2024-01-02T00:00:00.000Z", 154 + }, 155 + }, 156 + ], 157 + }, 158 + }); 159 + mockPrismaService.movieList.upsert.mockResolvedValue({ 160 + id: "list-favorites", 161 + rkey: "favorites-rkey", 162 + uri: "at://did:plc:abc123/xyz.opnshelf.list/favorites-rkey", 163 + cid: "cid-favorites", 164 + userDid: "did:plc:abc123", 165 + name: "Favorites", 166 + description: "Your favorite items", 167 + slug: "favorites", 168 + isDefault: true, 169 + createdAt: new Date("2024-01-02"), 170 + updatedAt: new Date("2024-01-02"), 171 + }); 172 + mockPutRecord.mockResolvedValue({ 173 + data: { 174 + uri: "at://did:plc:abc123/xyz.opnshelf.list/watchlist-rkey", 175 + cid: "cid-watchlist", 176 + }, 177 + }); 178 + mockPrismaService.movieList.create.mockResolvedValue({ 179 + id: "list-watchlist", 180 + rkey: "watchlist-rkey", 181 + uri: "at://did:plc:abc123/xyz.opnshelf.list/watchlist-rkey", 182 + cid: "cid-watchlist", 183 + userDid: "did:plc:abc123", 184 + name: "Watchlist", 185 + description: "Items you want to watch", 186 + slug: "watchlist", 187 + isDefault: true, 188 + createdAt: new Date("2024-01-01"), 189 + updatedAt: new Date("2024-01-01"), 190 + }); 191 + 192 + const result = await service.ensureDefaultLists("did:plc:abc123", { 193 + did: "did:plc:abc123", 194 + }); 195 + 196 + expect(mockListRecords).toHaveBeenCalledWith({ 197 + repo: "did:plc:abc123", 198 + collection: "xyz.opnshelf.list", 199 + limit: 100, 200 + cursor: undefined, 201 + }); 202 + expect(mockPrismaService.movieList.upsert).toHaveBeenCalledWith({ 203 + where: { rkey: "favorites-rkey" }, 204 + create: { 205 + rkey: "favorites-rkey", 206 + uri: "at://did:plc:abc123/xyz.opnshelf.list/favorites-rkey", 207 + cid: "cid-favorites", 208 + userDid: "did:plc:abc123", 209 + name: "Favorites", 210 + description: "Your favorite items", 211 + slug: "favorites", 212 + isDefault: true, 213 + }, 214 + update: { 215 + uri: "at://did:plc:abc123/xyz.opnshelf.list/favorites-rkey", 216 + cid: "cid-favorites", 217 + name: "Favorites", 218 + description: "Your favorite items", 219 + slug: "favorites", 220 + isDefault: true, 221 + }, 222 + }); 223 + expect(mockPutRecord).toHaveBeenCalledTimes(1); 224 + expect(mockPutRecord).toHaveBeenCalledWith( 225 + expect.objectContaining({ 226 + collection: "xyz.opnshelf.list", 227 + record: expect.objectContaining({ 228 + slug: "watchlist", 229 + }), 230 + }), 231 + ); 232 + expect(result.map((list) => list.slug)).toEqual([ 233 + "watchlist", 234 + "favorites", 235 + ]); 236 + }); 237 + 238 + it("does not create duplicates when both default lists already exist locally", async () => { 239 + mockPrismaService.movieList.findMany.mockResolvedValue([ 240 + { 241 + id: "list-watchlist", 242 + rkey: "watchlist-rkey", 243 + uri: "at://did:plc:abc123/xyz.opnshelf.list/watchlist-rkey", 244 + cid: "cid-watchlist", 245 + userDid: "did:plc:abc123", 246 + name: "Watchlist", 247 + description: "Items you want to watch", 248 + slug: "watchlist", 249 + isDefault: true, 250 + createdAt: new Date("2024-01-01"), 251 + updatedAt: new Date("2024-01-01"), 252 + }, 253 + { 254 + id: "list-favorites", 255 + rkey: "favorites-rkey", 256 + uri: "at://did:plc:abc123/xyz.opnshelf.list/favorites-rkey", 257 + cid: "cid-favorites", 258 + userDid: "did:plc:abc123", 259 + name: "Favorites", 260 + description: "Your favorite items", 261 + slug: "favorites", 262 + isDefault: true, 263 + createdAt: new Date("2024-01-02"), 264 + updatedAt: new Date("2024-01-02"), 265 + }, 266 + ]); 267 + 268 + const result = await service.ensureDefaultLists("did:plc:abc123", { 269 + did: "did:plc:abc123", 270 + }); 271 + 272 + expect(mockListRecords).not.toHaveBeenCalled(); 273 + expect(mockPutRecord).not.toHaveBeenCalled(); 274 + expect(result.map((list) => list.slug)).toEqual([ 275 + "watchlist", 276 + "favorites", 277 + ]); 278 + }); 104 279 }); 105 280 106 281 describe("getUserLists", () => {
+147 -5
backend/src/lists/lists.service.ts
··· 50 50 description: "Your favorite items", 51 51 }, 52 52 ]; 53 + const DEFAULT_LIST_SLUGS = new Set(DEFAULT_LISTS.map((list) => list.slug)); 54 + const LIST_RECORDS_PAGE_SIZE = 100; 53 55 54 56 @Injectable() 55 57 export class ListsService { ··· 208 210 const existingLists = await this.prisma.movieList.findMany({ 209 211 where: { userDid, isDefault: true }, 210 212 }); 213 + const hydratedLists = [...existingLists]; 211 214 212 215 const existingSlugs = new Set(existingLists.map((l) => l.slug)); 216 + 217 + if (existingSlugs.size < DEFAULT_LISTS.length) { 218 + const repoDefaults = await this.listRepoDefaultLists(userDid, session); 219 + 220 + for (const repoDefault of repoDefaults) { 221 + if (existingSlugs.has(repoDefault.slug)) { 222 + continue; 223 + } 224 + 225 + const indexedDefault = await this.prisma.movieList.upsert({ 226 + where: { rkey: repoDefault.rkey }, 227 + create: { 228 + rkey: repoDefault.rkey, 229 + uri: repoDefault.uri, 230 + cid: repoDefault.cid, 231 + userDid, 232 + name: repoDefault.name, 233 + description: repoDefault.description, 234 + slug: repoDefault.slug, 235 + isDefault: true, 236 + }, 237 + update: { 238 + uri: repoDefault.uri, 239 + cid: repoDefault.cid, 240 + name: repoDefault.name, 241 + description: repoDefault.description, 242 + slug: repoDefault.slug, 243 + isDefault: true, 244 + }, 245 + }); 246 + 247 + hydratedLists.push(indexedDefault); 248 + existingSlugs.add(repoDefault.slug); 249 + } 250 + } 251 + 213 252 const listsToCreate = DEFAULT_LISTS.filter( 214 253 (dl) => !existingSlugs.has(dl.slug), 215 254 ); 216 255 217 256 for (const defaultList of listsToCreate) { 218 - await this.createDefaultList(userDid, session, defaultList); 257 + const createdDefault = await this.createDefaultList( 258 + userDid, 259 + session, 260 + defaultList, 261 + ); 262 + hydratedLists.push({ 263 + id: createdDefault.id, 264 + rkey: createdDefault.rkey, 265 + uri: createdDefault.uri, 266 + cid: null, 267 + userDid: createdDefault.userDid, 268 + name: createdDefault.name, 269 + description: createdDefault.description ?? null, 270 + slug: createdDefault.slug, 271 + isDefault: createdDefault.isDefault, 272 + createdAt: new Date(createdDefault.createdAt), 273 + updatedAt: new Date(createdDefault.updatedAt), 274 + }); 275 + existingSlugs.add(defaultList.slug); 219 276 } 220 277 221 - const allLists = await this.prisma.movieList.findMany({ 222 - where: { userDid, isDefault: true }, 223 - orderBy: { createdAt: "asc" }, 224 - }); 278 + const allLists = 279 + listsToCreate.length > 0 || hydratedLists.length !== existingLists.length 280 + ? await this.prisma.movieList.findMany({ 281 + where: { userDid, isDefault: true }, 282 + orderBy: { createdAt: "asc" }, 283 + }) 284 + : hydratedLists.sort( 285 + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), 286 + ); 225 287 226 288 return allLists.map((list) => ({ 227 289 id: list.id, ··· 735 797 const uniqueSuffix = userDid.slice(-6); 736 798 737 799 return `${baseSlug}-${uniqueSuffix}`; 800 + } 801 + 802 + private async listRepoDefaultLists( 803 + userDid: string, 804 + session: ATSession, 805 + ): Promise< 806 + Array<{ 807 + rkey: string; 808 + uri: string; 809 + cid: string; 810 + name: string; 811 + description?: string; 812 + slug: string; 813 + }> 814 + > { 815 + const agent = new Agent( 816 + session as unknown as ConstructorParameters<typeof Agent>[0], 817 + ); 818 + const repoDefaults: Array<{ 819 + rkey: string; 820 + uri: string; 821 + cid: string; 822 + name: string; 823 + description?: string; 824 + slug: string; 825 + }> = []; 826 + let cursor: string | undefined; 827 + 828 + do { 829 + const response = await agent.com.atproto.repo.listRecords({ 830 + repo: session.did, 831 + collection: LIST_COLLECTION, 832 + limit: LIST_RECORDS_PAGE_SIZE, 833 + cursor, 834 + }); 835 + 836 + for (const record of response.data.records) { 837 + let parsedRecord: ListRecord; 838 + try { 839 + parsedRecord = listSchema.parse(record.value); 840 + } catch { 841 + this.logger.debug(`Skipping invalid repo list record: ${record.uri}`); 842 + continue; 843 + } 844 + 845 + if ( 846 + !parsedRecord.isDefault || 847 + !DEFAULT_LIST_SLUGS.has(parsedRecord.slug) 848 + ) { 849 + continue; 850 + } 851 + 852 + repoDefaults.push({ 853 + rkey: this.extractRkeyFromUri(record.uri, session.did), 854 + uri: record.uri, 855 + cid: record.cid, 856 + name: parsedRecord.name, 857 + description: parsedRecord.description, 858 + slug: parsedRecord.slug, 859 + }); 860 + } 861 + 862 + cursor = response.data.cursor; 863 + } while (cursor); 864 + 865 + this.logger.debug( 866 + `Found ${repoDefaults.length} default list records on repo for user ${userDid}`, 867 + ); 868 + 869 + return repoDefaults; 870 + } 871 + 872 + private extractRkeyFromUri(uri: string, userDid: string): string { 873 + const prefix = `at://${userDid}/${LIST_COLLECTION}/`; 874 + 875 + if (!uri.startsWith(prefix)) { 876 + throw new Error(`Unexpected list URI returned from repo: ${uri}`); 877 + } 878 + 879 + return uri.slice(prefix.length); 738 880 } 739 881 }
+11 -3
backend/src/social/social.controller.ts
··· 21 21 SocialSearchQueryDto, 22 22 UserRelationshipDto, 23 23 } from "./dto/social.dto"; 24 - import { SocialService } from "./social.service"; 24 + import { type ATSession, SocialService } from "./social.service"; 25 25 26 26 @ApiTags("social") 27 27 @UseGuards(AuthGuard) ··· 51 51 @Req() req: AuthenticatedRequest, 52 52 @Param("targetDid") targetDid: string, 53 53 ): Promise<UserRelationshipDto> { 54 - return this.socialService.follow(getViewerDid(req), targetDid); 54 + return this.socialService.follow( 55 + getViewerDid(req), 56 + req.user.session as ATSession, 57 + targetDid, 58 + ); 55 59 } 56 60 57 61 @Delete("follows/:targetDid") ··· 62 66 @Req() req: AuthenticatedRequest, 63 67 @Param("targetDid") targetDid: string, 64 68 ): Promise<void> { 65 - await this.socialService.unfollow(getViewerDid(req), targetDid); 69 + await this.socialService.unfollow( 70 + getViewerDid(req), 71 + req.user.session as ATSession, 72 + targetDid, 73 + ); 66 74 } 67 75 68 76 @Get("relationship/:targetDid")
+145 -31
backend/src/social/social.service.spec.ts
··· 2 2 import type { PrismaService } from "../prisma/prisma.service"; 3 3 import { SocialService } from "./social.service"; 4 4 5 + const mockPutRecord = jest.fn(); 6 + const mockDeleteRecord = jest.fn(); 7 + 8 + jest.mock("@atproto/api", () => ({ 9 + Agent: jest.fn().mockImplementation(() => ({ 10 + com: { 11 + atproto: { 12 + repo: { 13 + putRecord: mockPutRecord, 14 + deleteRecord: mockDeleteRecord, 15 + }, 16 + }, 17 + }, 18 + })), 19 + })); 20 + 21 + jest.mock("@atproto/common", () => ({ 22 + TID: { 23 + nextStr: jest.fn(() => "follow-rkey-123"), 24 + }, 25 + })); 26 + 27 + jest.mock("../lexicons/xyz/opnshelf/follow", () => ({ 28 + main: { 29 + build: jest.fn((data: Record<string, unknown>) => ({ 30 + $type: "xyz.opnshelf.follow", 31 + ...data, 32 + })), 33 + }, 34 + $nsid: "xyz.opnshelf.follow", 35 + })); 36 + 5 37 describe("SocialService", () => { 6 38 let service: SocialService; 7 39 ··· 12 44 }, 13 45 follow: { 14 46 count: jest.fn(), 15 - createMany: jest.fn(), 47 + create: jest.fn(), 16 48 deleteMany: jest.fn(), 17 49 findMany: jest.fn(), 50 + findFirst: jest.fn(), 51 + update: jest.fn(), 18 52 }, 19 53 trackedMovie: { 20 54 count: jest.fn(), ··· 30 64 }, 31 65 $queryRaw: jest.fn(), 32 66 } as unknown as PrismaService; 67 + const session = { did: "did:plc:self" }; 33 68 34 69 beforeEach(() => { 35 70 jest.clearAllMocks(); 71 + mockPutRecord.mockReset(); 72 + mockDeleteRecord.mockReset(); 36 73 service = new SocialService(prisma); 37 74 }); 38 75 ··· 40 77 prisma.user.findUnique = jest 41 78 .fn() 42 79 .mockResolvedValue({ did: "did:plc:target" }); 43 - prisma.follow.createMany = jest.fn().mockResolvedValue({ count: 1 }); 80 + prisma.follow.findFirst = jest 81 + .fn() 82 + .mockResolvedValueOnce(null) 83 + .mockResolvedValueOnce({ rkey: "follow-rkey-123" }); 84 + prisma.follow.create = jest.fn().mockResolvedValue({ 85 + followerDid: "did:plc:self", 86 + followingDid: "did:plc:target", 87 + rkey: "follow-rkey-123", 88 + }); 44 89 prisma.follow.count = jest 45 90 .fn() 46 91 .mockResolvedValueOnce(1) 47 92 .mockResolvedValueOnce(0) 48 93 .mockResolvedValueOnce(1) 49 94 .mockResolvedValueOnce(0); 95 + mockPutRecord.mockResolvedValue({ 96 + data: { 97 + uri: "at://did:plc:self/xyz.opnshelf.follow/follow-rkey-123", 98 + cid: "cid-follow-123", 99 + }, 100 + }); 50 101 51 102 await expect( 52 - service.follow("did:plc:self", "did:plc:target"), 103 + service.follow("did:plc:self", session, "did:plc:target"), 53 104 ).resolves.toEqual({ 54 105 targetDid: "did:plc:target", 55 106 isFollowing: true, ··· 58 109 }); 59 110 60 111 await expect( 61 - service.follow("did:plc:self", "did:plc:target"), 112 + service.follow("did:plc:self", session, "did:plc:target"), 62 113 ).resolves.toEqual({ 63 114 targetDid: "did:plc:target", 64 115 isFollowing: true, ··· 66 117 canFollow: true, 67 118 }); 68 119 69 - expect(prisma.follow.createMany).toHaveBeenCalledTimes(2); 70 - expect(prisma.follow.createMany).toHaveBeenCalledWith({ 71 - data: [ 72 - { 73 - followerDid: "did:plc:self", 74 - followingDid: "did:plc:target", 75 - }, 76 - ], 77 - skipDuplicates: true, 120 + expect(mockPutRecord).toHaveBeenCalledTimes(1); 121 + expect(mockPutRecord).toHaveBeenCalledWith({ 122 + repo: "did:plc:self", 123 + collection: "xyz.opnshelf.follow", 124 + rkey: "follow-rkey-123", 125 + record: expect.objectContaining({ 126 + $type: "xyz.opnshelf.follow", 127 + subjectDid: "did:plc:target", 128 + }), 129 + validate: false, 130 + }); 131 + expect(prisma.follow.create).toHaveBeenCalledTimes(1); 132 + expect(prisma.follow.create).toHaveBeenCalledWith({ 133 + data: expect.objectContaining({ 134 + followerDid: "did:plc:self", 135 + followingDid: "did:plc:target", 136 + rkey: "follow-rkey-123", 137 + uri: "at://did:plc:self/xyz.opnshelf.follow/follow-rkey-123", 138 + cid: "cid-follow-123", 139 + }), 78 140 }); 79 141 }); 80 142 ··· 82 144 prisma.user.findUnique = jest 83 145 .fn() 84 146 .mockResolvedValue({ did: "did:plc:target" }); 147 + prisma.follow.findFirst = jest 148 + .fn() 149 + .mockResolvedValueOnce({ rkey: "follow-rkey-123" }) 150 + .mockResolvedValueOnce(null); 85 151 prisma.follow.deleteMany = jest 86 152 .fn() 87 153 .mockResolvedValueOnce({ count: 1 }) 88 154 .mockResolvedValueOnce({ count: 0 }); 155 + mockDeleteRecord.mockResolvedValue({}); 89 156 90 157 await expect( 91 - service.unfollow("did:plc:self", "did:plc:target"), 158 + service.unfollow("did:plc:self", session, "did:plc:target"), 92 159 ).resolves.toBeUndefined(); 93 160 await expect( 94 - service.unfollow("did:plc:self", "did:plc:target"), 161 + service.unfollow("did:plc:self", session, "did:plc:target"), 95 162 ).resolves.toBeUndefined(); 96 163 164 + expect(mockDeleteRecord).toHaveBeenCalledTimes(1); 165 + expect(mockDeleteRecord).toHaveBeenCalledWith({ 166 + repo: "did:plc:self", 167 + collection: "xyz.opnshelf.follow", 168 + rkey: "follow-rkey-123", 169 + }); 97 170 expect(prisma.follow.deleteMany).toHaveBeenCalledTimes(2); 98 171 expect(prisma.follow.deleteMany).toHaveBeenCalledWith({ 99 172 where: { ··· 105 178 106 179 it("rejects self-follow", async () => { 107 180 await expect( 108 - service.follow("did:plc:self", "did:plc:self"), 181 + service.follow("did:plc:self", session, "did:plc:self"), 109 182 ).rejects.toThrow(BadRequestException); 110 183 expect(prisma.user.findUnique).not.toHaveBeenCalled(); 111 184 }); ··· 375 448 }); 376 449 377 450 it("can reflect public profile counts after follow and unfollow", async () => { 378 - const follows = new Set<string>(); 451 + const follows = new Map<string, { rkey?: string }>(); 379 452 const statefulPrisma = createStatefulPrisma(follows); 380 453 const statefulService = new SocialService( 381 454 statefulPrisma as unknown as PrismaService, 382 455 ); 456 + mockPutRecord.mockResolvedValue({ 457 + data: { 458 + uri: "at://did:plc:self/xyz.opnshelf.follow/follow-rkey-123", 459 + cid: "cid-follow-123", 460 + }, 461 + }); 462 + mockDeleteRecord.mockResolvedValue({}); 383 463 384 - await statefulService.follow("did:plc:self", "did:plc:target"); 464 + await statefulService.follow("did:plc:self", session, "did:plc:target"); 385 465 const followedProfile = await statefulService.getFollowers( 386 466 "did:plc:self", 387 467 "target", ··· 394 474 followingCount: 1, 395 475 }); 396 476 397 - await statefulService.unfollow("did:plc:self", "did:plc:target"); 477 + await statefulService.unfollow("did:plc:self", session, "did:plc:target"); 398 478 const relationship = await statefulService.getRelationship( 399 479 "did:plc:self", 400 480 "did:plc:target", ··· 433 513 }; 434 514 } 435 515 436 - function createStatefulPrisma(follows: Set<string>) { 516 + function createStatefulPrisma(follows: Map<string, { rkey?: string }>) { 437 517 const users = [ 438 518 { did: "did:plc:self", handle: "self", displayName: "Self", avatar: null }, 439 519 { ··· 480 560 .map((user) => ({ 481 561 ...user, 482 562 _count: { 483 - followers: [...follows].filter((entry) => 563 + followers: [...follows.keys()].filter((entry) => 484 564 entry.endsWith(`->${user.did}`), 485 565 ).length, 486 - following: [...follows].filter((entry) => 566 + following: [...follows.keys()].filter((entry) => 487 567 entry.startsWith(`${user.did}->`), 488 568 ).length, 489 569 }, ··· 502 582 where: { followerDid?: string; followingDid?: string }; 503 583 }) => { 504 584 return Promise.resolve( 505 - [...follows].filter((entry) => { 585 + [...follows.keys()].filter((entry) => { 506 586 const [followerDid, followingDid] = entry.split("->"); 507 587 return ( 508 588 (where.followerDid ··· 516 596 ); 517 597 }, 518 598 ), 519 - createMany: jest 599 + create: jest.fn().mockImplementation( 600 + ({ 601 + data, 602 + }: { 603 + data: { 604 + followerDid: string; 605 + followingDid: string; 606 + rkey?: string | null; 607 + }; 608 + }) => { 609 + follows.set(`${data.followerDid}->${data.followingDid}`, { 610 + rkey: data.rkey ?? undefined, 611 + }); 612 + return Promise.resolve(data); 613 + }, 614 + ), 615 + findFirst: jest 520 616 .fn() 521 617 .mockImplementation( 522 618 ({ 523 - data, 619 + where, 524 620 }: { 525 - data: Array<{ followerDid: string; followingDid: string }>; 621 + where: { followerDid: string; followingDid: string }; 526 622 }) => { 527 - for (const follow of data) { 528 - follows.add(`${follow.followerDid}->${follow.followingDid}`); 529 - } 530 - return Promise.resolve({ count: data.length }); 623 + const entry = follows.get( 624 + `${where.followerDid}->${where.followingDid}`, 625 + ); 626 + return Promise.resolve(entry ? { rkey: entry.rkey ?? null } : null); 531 627 }, 532 628 ), 629 + update: jest.fn().mockImplementation( 630 + ({ 631 + where, 632 + data, 633 + }: { 634 + where: { 635 + followerDid_followingDid: { 636 + followerDid: string; 637 + followingDid: string; 638 + }; 639 + }; 640 + data: { rkey?: string | null }; 641 + }) => { 642 + const key = `${where.followerDid_followingDid.followerDid}->${where.followerDid_followingDid.followingDid}`; 643 + follows.set(key, { rkey: data.rkey ?? undefined }); 644 + return Promise.resolve({}); 645 + }, 646 + ), 533 647 deleteMany: jest 534 648 .fn() 535 649 .mockImplementation( ··· 545 659 findMany: jest 546 660 .fn() 547 661 .mockImplementation(({ where }: { where: Record<string, unknown> }) => { 548 - const entries = [...follows] 662 + const entries = [...follows.keys()] 549 663 .map((entry) => { 550 664 const [followerDid, followingDid] = entry.split("->"); 551 665 return { followerDid, followingDid };
+173 -5
backend/src/social/social.service.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { TID } from "@atproto/common"; 1 3 import { 2 4 BadRequestException, 3 5 Injectable, 6 + Logger, 4 7 NotFoundException, 5 8 } from "@nestjs/common"; 9 + import { 10 + $nsid as FOLLOW_COLLECTION, 11 + main as followSchema, 12 + } from "../lexicons/xyz/opnshelf/follow"; 13 + import type { Main as FollowRecord } from "../lexicons/xyz/opnshelf/follow.defs"; 6 14 import { Prisma } from "../generated/client"; 7 15 import { PrismaService } from "../prisma/prisma.service"; 8 16 import type { ··· 55 63 hasPreviousPage: boolean; 56 64 }; 57 65 66 + export interface ATSession { 67 + did: string; 68 + } 69 + 58 70 const DEFAULT_SOCIAL_PAGE_SIZE = 20; 59 71 const MAX_SOCIAL_PAGE_SIZE = 50; 60 72 const DEFAULT_FEED_PAGE_SIZE = 10; ··· 62 74 63 75 @Injectable() 64 76 export class SocialService { 77 + private readonly logger = new Logger(SocialService.name); 78 + 65 79 constructor(private readonly prisma: PrismaService) {} 66 80 67 81 async searchPeople( ··· 110 124 111 125 async follow( 112 126 viewerDid: string, 127 + session: ATSession, 113 128 targetDid: string, 114 129 ): Promise<UserRelationshipDto> { 115 130 await this.assertCanFollow(viewerDid, targetDid); 131 + const existingFollow = await this.prisma.follow.findFirst({ 132 + where: { 133 + followerDid: viewerDid, 134 + followingDid: targetDid, 135 + }, 136 + select: { rkey: true }, 137 + }); 116 138 117 - await this.prisma.follow.createMany({ 118 - data: [{ followerDid: viewerDid, followingDid: targetDid }], 119 - skipDuplicates: true, 120 - }); 139 + if (existingFollow?.rkey) { 140 + return this.getRelationship(viewerDid, targetDid); 141 + } 142 + 143 + const { rkey, uri, cid, createdAt } = await this.createFollowRecord( 144 + session, 145 + targetDid, 146 + ); 147 + 148 + if (existingFollow) { 149 + await this.prisma.follow.update({ 150 + where: { 151 + followerDid_followingDid: { 152 + followerDid: viewerDid, 153 + followingDid: targetDid, 154 + }, 155 + }, 156 + data: { rkey, uri, cid, createdAt }, 157 + }); 158 + } else { 159 + await this.prisma.follow.create({ 160 + data: { 161 + followerDid: viewerDid, 162 + followingDid: targetDid, 163 + rkey, 164 + uri, 165 + cid, 166 + createdAt, 167 + }, 168 + }); 169 + } 121 170 122 171 return this.getRelationship(viewerDid, targetDid); 123 172 } 124 173 125 - async unfollow(viewerDid: string, targetDid: string): Promise<void> { 174 + async unfollow( 175 + viewerDid: string, 176 + session: ATSession, 177 + targetDid: string, 178 + ): Promise<void> { 126 179 await this.assertTargetUserExists(targetDid); 127 180 128 181 if (viewerDid === targetDid) { 129 182 return; 183 + } 184 + 185 + const existingFollow = await this.prisma.follow.findFirst({ 186 + where: { 187 + followerDid: viewerDid, 188 + followingDid: targetDid, 189 + }, 190 + select: { rkey: true }, 191 + }); 192 + 193 + if (existingFollow?.rkey) { 194 + await this.deleteFollowRecord(session, existingFollow.rkey); 130 195 } 131 196 132 197 await this.prisma.follow.deleteMany({ ··· 383 448 }; 384 449 } 385 450 451 + async indexFollowRecord( 452 + followerDid: string, 453 + rkey: string, 454 + cid: string | undefined, 455 + record: FollowRecord, 456 + uri?: string, 457 + ) { 458 + await this.assertTargetUserExists(record.subjectDid); 459 + 460 + const existingFollow = await this.prisma.follow.findFirst({ 461 + where: { 462 + followerDid, 463 + followingDid: record.subjectDid, 464 + }, 465 + select: { 466 + followerDid: true, 467 + followingDid: true, 468 + }, 469 + }); 470 + 471 + const data = { 472 + rkey, 473 + cid, 474 + uri: uri ?? `at://${followerDid}/${FOLLOW_COLLECTION}/${rkey}`, 475 + createdAt: new Date(record.createdAt), 476 + }; 477 + 478 + if (existingFollow) { 479 + return this.prisma.follow.update({ 480 + where: { 481 + followerDid_followingDid: { 482 + followerDid, 483 + followingDid: record.subjectDid, 484 + }, 485 + }, 486 + data, 487 + }); 488 + } 489 + 490 + return this.prisma.follow.create({ 491 + data: { 492 + followerDid, 493 + followingDid: record.subjectDid, 494 + ...data, 495 + }, 496 + }); 497 + } 498 + 499 + async deleteFollowRecordIndex(followerDid: string, rkey: string) { 500 + return this.prisma.follow.deleteMany({ 501 + where: { 502 + followerDid, 503 + rkey, 504 + }, 505 + }); 506 + } 507 + 386 508 private async assertCanFollow(viewerDid: string, targetDid: string) { 387 509 if (viewerDid === targetDid) { 388 510 throw new BadRequestException("Users cannot follow themselves"); ··· 414 536 } 415 537 416 538 return user; 539 + } 540 + 541 + private async createFollowRecord(session: ATSession, targetDid: string) { 542 + const rkey = TID.nextStr(); 543 + const createdAt = new Date(); 544 + const record: FollowRecord = followSchema.build({ 545 + subjectDid: targetDid, 546 + createdAt: createdAt.toISOString(), 547 + }); 548 + 549 + const agent = new Agent( 550 + session as unknown as ConstructorParameters<typeof Agent>[0], 551 + ); 552 + const response = await agent.com.atproto.repo.putRecord({ 553 + repo: session.did, 554 + collection: FOLLOW_COLLECTION, 555 + rkey, 556 + record, 557 + validate: false, 558 + }); 559 + 560 + return { 561 + rkey, 562 + uri: response.data.uri, 563 + cid: response.data.cid, 564 + createdAt, 565 + }; 566 + } 567 + 568 + private async deleteFollowRecord(session: ATSession, rkey: string) { 569 + const agent = new Agent( 570 + session as unknown as ConstructorParameters<typeof Agent>[0], 571 + ); 572 + 573 + try { 574 + await agent.com.atproto.repo.deleteRecord({ 575 + repo: session.did, 576 + collection: FOLLOW_COLLECTION, 577 + rkey, 578 + }); 579 + } catch (error) { 580 + this.logger.warn( 581 + `Failed to delete follow record ${rkey} from PDS`, 582 + error, 583 + ); 584 + } 417 585 } 418 586 419 587 private async buildSocialUserCards(
+1 -1
backend/src/users/dto/user-settings.dto.ts
··· 24 24 export class DeleteUserAccountDto { 25 25 @ApiProperty({ 26 26 description: 27 - "Whether to delete the user's watch history from their PDS. If false, the data remains on their PDS.", 27 + "Whether to delete the user's OpnShelf data from their PDS, including watch history, follows, lists, and list items. If false, the data remains on their PDS.", 28 28 default: false, 29 29 }) 30 30 @IsBoolean()
+247
backend/src/users/user-deletion.service.spec.ts
··· 1 + import { BadGatewayException, NotFoundException } from "@nestjs/common"; 2 + 3 + const mockListRecords = jest.fn(); 4 + const mockDeleteRecord = jest.fn(); 5 + 6 + jest.mock("@atproto/api", () => ({ 7 + Agent: jest.fn().mockImplementation(() => ({ 8 + com: { 9 + atproto: { 10 + repo: { 11 + listRecords: mockListRecords, 12 + deleteRecord: mockDeleteRecord, 13 + }, 14 + }, 15 + }, 16 + })), 17 + })); 18 + 19 + import { PrismaService } from "../prisma/prisma.service"; 20 + import { UserDeletionService } from "./user-deletion.service"; 21 + 22 + describe("UserDeletionService", () => { 23 + let service: UserDeletionService; 24 + 25 + const prisma = { 26 + user: { 27 + findUnique: jest.fn(), 28 + delete: jest.fn(), 29 + }, 30 + trackedMovie: { 31 + findMany: jest.fn(), 32 + }, 33 + trackedEpisode: { 34 + findMany: jest.fn(), 35 + }, 36 + follow: { 37 + findMany: jest.fn(), 38 + }, 39 + listItem: { 40 + findMany: jest.fn(), 41 + }, 42 + movieList: { 43 + findMany: jest.fn(), 44 + }, 45 + } as unknown as PrismaService; 46 + 47 + beforeEach(() => { 48 + jest.clearAllMocks(); 49 + 50 + prisma.user.findUnique = jest 51 + .fn() 52 + .mockResolvedValue({ did: "did:plc:test" }); 53 + prisma.user.delete = jest.fn().mockResolvedValue(undefined); 54 + prisma.trackedMovie.findMany = jest.fn().mockResolvedValue([]); 55 + prisma.trackedEpisode.findMany = jest.fn().mockResolvedValue([]); 56 + prisma.follow.findMany = jest.fn().mockResolvedValue([]); 57 + prisma.listItem.findMany = jest.fn().mockResolvedValue([]); 58 + prisma.movieList.findMany = jest.fn().mockResolvedValue([]); 59 + 60 + service = new UserDeletionService(prisma); 61 + }); 62 + 63 + it("deletes all repo list items and lists, including favorites missing from Prisma", async () => { 64 + mockListRecords 65 + .mockResolvedValueOnce({ 66 + data: { 67 + records: [ 68 + { 69 + uri: "at://did:plc:test/xyz.opnshelf.listItem/list-item-1", 70 + cid: "cid-1", 71 + value: {}, 72 + }, 73 + { 74 + uri: "at://did:plc:test/xyz.opnshelf.listItem/list-item-2", 75 + cid: "cid-2", 76 + value: {}, 77 + }, 78 + ], 79 + cursor: "cursor-2", 80 + }, 81 + }) 82 + .mockResolvedValueOnce({ 83 + data: { 84 + records: [ 85 + { 86 + uri: "at://did:plc:test/xyz.opnshelf.listItem/list-item-3", 87 + cid: "cid-3", 88 + value: {}, 89 + }, 90 + ], 91 + }, 92 + }) 93 + .mockResolvedValueOnce({ 94 + data: { 95 + records: [ 96 + { 97 + uri: "at://did:plc:test/xyz.opnshelf.list/favorites", 98 + cid: "cid-4", 99 + value: {}, 100 + }, 101 + { 102 + uri: "at://did:plc:test/xyz.opnshelf.list/custom-list", 103 + cid: "cid-5", 104 + value: {}, 105 + }, 106 + ], 107 + }, 108 + }); 109 + 110 + await service.deleteUser("did:plc:test", { did: "did:plc:test" }, true); 111 + 112 + expect(mockListRecords).toHaveBeenNthCalledWith(1, { 113 + repo: "did:plc:test", 114 + collection: "xyz.opnshelf.listItem", 115 + limit: 100, 116 + cursor: undefined, 117 + }); 118 + expect(mockListRecords).toHaveBeenNthCalledWith(2, { 119 + repo: "did:plc:test", 120 + collection: "xyz.opnshelf.listItem", 121 + limit: 100, 122 + cursor: "cursor-2", 123 + }); 124 + expect(mockListRecords).toHaveBeenNthCalledWith(3, { 125 + repo: "did:plc:test", 126 + collection: "xyz.opnshelf.list", 127 + limit: 100, 128 + cursor: undefined, 129 + }); 130 + expect(mockDeleteRecord.mock.calls).toEqual([ 131 + [ 132 + { 133 + repo: "did:plc:test", 134 + collection: "xyz.opnshelf.listItem", 135 + rkey: "list-item-1", 136 + }, 137 + ], 138 + [ 139 + { 140 + repo: "did:plc:test", 141 + collection: "xyz.opnshelf.listItem", 142 + rkey: "list-item-2", 143 + }, 144 + ], 145 + [ 146 + { 147 + repo: "did:plc:test", 148 + collection: "xyz.opnshelf.listItem", 149 + rkey: "list-item-3", 150 + }, 151 + ], 152 + [ 153 + { 154 + repo: "did:plc:test", 155 + collection: "xyz.opnshelf.list", 156 + rkey: "favorites", 157 + }, 158 + ], 159 + [ 160 + { 161 + repo: "did:plc:test", 162 + collection: "xyz.opnshelf.list", 163 + rkey: "custom-list", 164 + }, 165 + ], 166 + ]); 167 + expect(prisma.listItem.findMany).not.toHaveBeenCalled(); 168 + expect(prisma.movieList.findMany).not.toHaveBeenCalled(); 169 + expect(prisma.user.delete).toHaveBeenCalledWith({ 170 + where: { did: "did:plc:test" }, 171 + }); 172 + }); 173 + 174 + it("aborts account deletion when listing repo records fails", async () => { 175 + mockListRecords.mockRejectedValueOnce(new Error("PDS unavailable")); 176 + 177 + await expect( 178 + service.deleteUser("did:plc:test", { did: "did:plc:test" }, true), 179 + ).rejects.toThrow(BadGatewayException); 180 + expect(prisma.user.delete).not.toHaveBeenCalled(); 181 + }); 182 + 183 + it("aborts account deletion when deleting a repo list record fails", async () => { 184 + mockListRecords 185 + .mockResolvedValueOnce({ 186 + data: { records: [] }, 187 + }) 188 + .mockResolvedValueOnce({ 189 + data: { 190 + records: [ 191 + { 192 + uri: "at://did:plc:test/xyz.opnshelf.list/favorites", 193 + cid: "cid-1", 194 + value: {}, 195 + }, 196 + ], 197 + }, 198 + }); 199 + mockDeleteRecord.mockRejectedValueOnce({ 200 + status: 500, 201 + error: "InternalError", 202 + }); 203 + 204 + await expect( 205 + service.deleteUser("did:plc:test", { did: "did:plc:test" }, true), 206 + ).rejects.toThrow(BadGatewayException); 207 + expect(prisma.user.delete).not.toHaveBeenCalled(); 208 + }); 209 + 210 + it("treats already-missing repo records as deleted", async () => { 211 + mockListRecords 212 + .mockResolvedValueOnce({ 213 + data: { records: [] }, 214 + }) 215 + .mockResolvedValueOnce({ 216 + data: { 217 + records: [ 218 + { 219 + uri: "at://did:plc:test/xyz.opnshelf.list/favorites", 220 + cid: "cid-1", 221 + value: {}, 222 + }, 223 + ], 224 + }, 225 + }); 226 + mockDeleteRecord.mockRejectedValueOnce({ 227 + status: 404, 228 + error: "RecordNotFound", 229 + message: "RecordNotFound", 230 + }); 231 + 232 + await expect( 233 + service.deleteUser("did:plc:test", { did: "did:plc:test" }, true), 234 + ).resolves.toBeUndefined(); 235 + expect(prisma.user.delete).toHaveBeenCalledWith({ 236 + where: { did: "did:plc:test" }, 237 + }); 238 + }); 239 + 240 + it("throws when deleting a missing user", async () => { 241 + prisma.user.findUnique = jest.fn().mockResolvedValue(null); 242 + 243 + await expect( 244 + service.deleteUser("did:plc:missing", { did: "did:plc:missing" }, false), 245 + ).rejects.toThrow(NotFoundException); 246 + }); 247 + });
+182 -55
backend/src/users/user-deletion.service.ts
··· 1 1 import { Agent } from "@atproto/api"; 2 - import { Injectable, Logger, NotFoundException } from "@nestjs/common"; 2 + import { 3 + BadGatewayException, 4 + Injectable, 5 + Logger, 6 + NotFoundException, 7 + } from "@nestjs/common"; 3 8 import { $nsid as EPISODE_COLLECTION } from "../lexicons/xyz/opnshelf/episode"; 9 + import { $nsid as FOLLOW_COLLECTION } from "../lexicons/xyz/opnshelf/follow"; 4 10 import { $nsid as LIST_COLLECTION } from "../lexicons/xyz/opnshelf/list"; 5 11 import { $nsid as LIST_ITEM_COLLECTION } from "../lexicons/xyz/opnshelf/listItem"; 6 12 import { $nsid as MOVIE_COLLECTION } from "../lexicons/xyz/opnshelf/movie"; ··· 9 15 interface ATSession { 10 16 did: string; 11 17 } 18 + 19 + const PDS_DELETION_FAILURE_MESSAGE = 20 + "Failed to delete OpnShelf data from your PDS. Your account was not deleted."; 21 + const RECORDS_PAGE_SIZE = 100; 12 22 13 23 @Injectable() 14 24 export class UserDeletionService { ··· 39 49 } 40 50 41 51 private async deletePdsRecords(did: string, session: ATSession) { 42 - try { 43 - const agent = new Agent( 44 - session as unknown as ConstructorParameters<typeof Agent>[0], 52 + const agent = new Agent( 53 + session as unknown as ConstructorParameters<typeof Agent>[0], 54 + ); 55 + 56 + const trackedMovies = await this.prisma.trackedMovie.findMany({ 57 + where: { userDid: did }, 58 + }); 59 + 60 + for (const tracked of trackedMovies) { 61 + await this.tryDeleteRecord( 62 + agent, 63 + session.did, 64 + MOVIE_COLLECTION, 65 + tracked.rkey, 66 + `Failed to delete record ${tracked.rkey} from PDS`, 45 67 ); 68 + } 46 69 47 - const trackedMovies = await this.prisma.trackedMovie.findMany({ 48 - where: { userDid: did }, 49 - }); 70 + const trackedEpisodes = await this.prisma.trackedEpisode.findMany({ 71 + where: { userDid: did }, 72 + }); 50 73 51 - for (const tracked of trackedMovies) { 52 - await this.tryDeleteRecord( 53 - agent, 54 - session.did, 55 - MOVIE_COLLECTION, 56 - tracked.rkey, 57 - `Failed to delete record ${tracked.rkey} from PDS`, 58 - ); 74 + for (const tracked of trackedEpisodes) { 75 + await this.tryDeleteRecord( 76 + agent, 77 + session.did, 78 + EPISODE_COLLECTION, 79 + tracked.rkey, 80 + `Failed to delete episode record ${tracked.rkey} from PDS`, 81 + ); 82 + } 83 + 84 + const follows = await this.prisma.follow.findMany({ 85 + where: { followerDid: did, rkey: { not: null } }, 86 + select: { rkey: true }, 87 + }); 88 + 89 + for (const follow of follows) { 90 + if (!follow.rkey) { 91 + continue; 59 92 } 60 93 61 - const trackedEpisodes = await this.prisma.trackedEpisode.findMany({ 62 - where: { userDid: did }, 94 + await this.tryDeleteRecord( 95 + agent, 96 + session.did, 97 + FOLLOW_COLLECTION, 98 + follow.rkey, 99 + `Failed to delete follow ${follow.rkey} from PDS`, 100 + ); 101 + } 102 + 103 + await this.deleteRepoCollectionRecords( 104 + agent, 105 + session.did, 106 + LIST_ITEM_COLLECTION, 107 + did, 108 + ); 109 + await this.deleteRepoCollectionRecords( 110 + agent, 111 + session.did, 112 + LIST_COLLECTION, 113 + did, 114 + ); 115 + } 116 + 117 + private async tryDeleteRecord( 118 + agent: Agent, 119 + repoDid: string, 120 + collection: string, 121 + rkey: string, 122 + warnPrefix: string, 123 + ) { 124 + try { 125 + await agent.com.atproto.repo.deleteRecord({ 126 + repo: repoDid, 127 + collection, 128 + rkey, 63 129 }); 130 + } catch (error) { 131 + this.logger.warn(`${warnPrefix}: ${error}`); 132 + } 133 + } 64 134 65 - for (const tracked of trackedEpisodes) { 66 - await this.tryDeleteRecord( 67 - agent, 68 - session.did, 69 - EPISODE_COLLECTION, 70 - tracked.rkey, 71 - `Failed to delete episode record ${tracked.rkey} from PDS`, 72 - ); 73 - } 135 + private async deleteRepoCollectionRecords( 136 + agent: Agent, 137 + repoDid: string, 138 + collection: string, 139 + userDid: string, 140 + ): Promise<void> { 141 + const rkeys = await this.listRepoRecordKeys( 142 + agent, 143 + repoDid, 144 + collection, 145 + userDid, 146 + ); 147 + 148 + for (const rkey of rkeys) { 149 + await this.deleteRepoRecordOrThrow( 150 + agent, 151 + repoDid, 152 + collection, 153 + rkey, 154 + userDid, 155 + ); 156 + } 157 + } 74 158 75 - const listItems = await this.prisma.listItem.findMany({ 76 - where: { list: { userDid: did } }, 77 - }); 159 + private async listRepoRecordKeys( 160 + agent: Agent, 161 + repoDid: string, 162 + collection: string, 163 + userDid: string, 164 + ): Promise<string[]> { 165 + const rkeys: string[] = []; 166 + let cursor: string | undefined; 78 167 79 - for (const item of listItems) { 80 - await this.tryDeleteRecord( 81 - agent, 82 - session.did, 83 - LIST_ITEM_COLLECTION, 84 - item.rkey, 85 - `Failed to delete list item ${item.rkey} from PDS`, 86 - ); 87 - } 168 + try { 169 + do { 170 + const response = await agent.com.atproto.repo.listRecords({ 171 + repo: repoDid, 172 + collection, 173 + limit: RECORDS_PAGE_SIZE, 174 + cursor, 175 + }); 88 176 89 - const lists = await this.prisma.movieList.findMany({ 90 - where: { userDid: did }, 91 - }); 177 + for (const record of response.data.records) { 178 + rkeys.push(this.extractRkeyFromUri(record.uri, repoDid, collection)); 179 + } 92 180 93 - for (const list of lists) { 94 - await this.tryDeleteRecord( 95 - agent, 96 - session.did, 97 - LIST_COLLECTION, 98 - list.rkey, 99 - `Failed to delete list ${list.rkey} from PDS`, 100 - ); 101 - } 181 + cursor = response.data.cursor; 182 + } while (cursor); 102 183 } catch (error) { 103 - this.logger.error(`Failed to delete PDS records for user ${did}`, error); 184 + this.logger.error( 185 + `Failed to list ${collection} records from PDS for user ${userDid}`, 186 + error, 187 + ); 188 + throw new BadGatewayException(PDS_DELETION_FAILURE_MESSAGE); 104 189 } 190 + 191 + return rkeys; 105 192 } 106 193 107 - private async tryDeleteRecord( 194 + private extractRkeyFromUri( 195 + uri: string, 196 + repoDid: string, 197 + collection: string, 198 + ): string { 199 + const prefix = `at://${repoDid}/${collection}/`; 200 + 201 + if (!uri.startsWith(prefix)) { 202 + throw new Error(`Unexpected record URI returned from PDS: ${uri}`); 203 + } 204 + 205 + return uri.slice(prefix.length); 206 + } 207 + 208 + private async deleteRepoRecordOrThrow( 108 209 agent: Agent, 109 210 repoDid: string, 110 211 collection: string, 111 212 rkey: string, 112 - warnPrefix: string, 113 - ) { 213 + userDid: string, 214 + ): Promise<void> { 114 215 try { 115 216 await agent.com.atproto.repo.deleteRecord({ 116 217 repo: repoDid, ··· 118 219 rkey, 119 220 }); 120 221 } catch (error) { 121 - this.logger.warn(`${warnPrefix}: ${error}`); 222 + if (this.isRecordMissingError(error)) { 223 + return; 224 + } 225 + 226 + this.logger.error( 227 + `Failed to delete ${collection} record ${rkey} from PDS for user ${userDid}`, 228 + error, 229 + ); 230 + throw new BadGatewayException(PDS_DELETION_FAILURE_MESSAGE); 231 + } 232 + } 233 + 234 + private isRecordMissingError(error: unknown): boolean { 235 + if (!error || typeof error !== "object") { 236 + return false; 122 237 } 238 + 239 + const candidate = error as { 240 + error?: string; 241 + status?: number; 242 + message?: string; 243 + }; 244 + 245 + return ( 246 + candidate.status === 404 || 247 + candidate.error === "RecordNotFound" || 248 + candidate.message?.includes("RecordNotFound") === true 249 + ); 123 250 } 124 251 }
+34 -1
backend/src/users/users.controller.spec.ts
··· 1 - import { BadRequestException } from "@nestjs/common"; 1 + import { BadGatewayException, BadRequestException } from "@nestjs/common"; 2 2 import { Test, type TestingModule } from "@nestjs/testing"; 3 3 import type { AuthenticatedRequest } from "../auth/types"; 4 4 import { UsersController } from "./users.controller"; ··· 187 187 req, 188 188 ), 189 189 ).resolves.toMatchObject({ imported: 1, skipped: 0, failed: 0 }); 190 + }); 191 + 192 + it("deletes the current account and forwards the PDS deletion flag", async () => { 193 + usersService.deleteUser.mockResolvedValue(undefined); 194 + 195 + const req = { 196 + user: { did: "did:plc:abc", session: { did: "did:plc:abc" } }, 197 + } as AuthenticatedRequest; 198 + 199 + await expect( 200 + controller.deleteMyAccount({ deletePDSData: true }, req), 201 + ).resolves.toBeUndefined(); 202 + expect(usersService.deleteUser).toHaveBeenCalledWith( 203 + "did:plc:abc", 204 + { did: "did:plc:abc" }, 205 + true, 206 + ); 207 + }); 208 + 209 + it("propagates delete-account PDS cleanup failures", async () => { 210 + usersService.deleteUser.mockRejectedValue( 211 + new BadGatewayException( 212 + "Failed to delete OpnShelf data from your PDS. Your account was not deleted.", 213 + ), 214 + ); 215 + 216 + const req = { 217 + user: { did: "did:plc:abc", session: { did: "did:plc:abc" } }, 218 + } as AuthenticatedRequest; 219 + 220 + await expect( 221 + controller.deleteMyAccount({ deletePDSData: true }, req), 222 + ).rejects.toThrow(BadGatewayException); 190 223 }); 191 224 192 225 it("rejects import when session is missing", async () => {
+5
backend/src/users/users.controller.ts
··· 114 114 @ApiOperation({ summary: "Delete current user's account" }) 115 115 @ApiResponse({ status: 204, description: "Account deleted successfully" }) 116 116 @ApiResponse({ status: 401, description: "Not authenticated" }) 117 + @ApiResponse({ 118 + status: 502, 119 + description: 120 + "Failed to delete OpnShelf data from the user's PDS, so the account was not deleted", 121 + }) 117 122 async deleteMyAccount( 118 123 @Body() dto: DeleteUserAccountDto, 119 124 @Req() req: AuthenticatedRequest,
+25
backend/src/users/users.service.spec.ts
··· 178 178 }); 179 179 }); 180 180 181 + it("delegates account deletion with the PDS deletion flag", async () => { 182 + (userDeletionService.deleteUser as jest.Mock).mockResolvedValue(undefined); 183 + 184 + await expect( 185 + service.deleteUser("did:plc:123", { did: "did:plc:123" }, true), 186 + ).resolves.toBeUndefined(); 187 + expect(userDeletionService.deleteUser).toHaveBeenCalledWith( 188 + "did:plc:123", 189 + { did: "did:plc:123" }, 190 + true, 191 + ); 192 + }); 193 + 194 + it("propagates account deletion failures from PDS cleanup", async () => { 195 + (userDeletionService.deleteUser as jest.Mock).mockRejectedValue( 196 + new BadGatewayException( 197 + "Failed to delete OpnShelf data from your PDS. Your account was not deleted.", 198 + ), 199 + ); 200 + 201 + await expect( 202 + service.deleteUser("did:plc:123", { did: "did:plc:123" }, true), 203 + ).rejects.toThrow(BadGatewayException); 204 + }); 205 + 181 206 it("imports Bluesky follows with pagination and creates only missing local follows", async () => { 182 207 prisma.user.findUnique = jest 183 208 .fn()
+26
lexicons/app/opnshelf/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.opnshelf.follow", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "An OpnShelf follow relationship stored on a user's PDS", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subjectDid", "createdAt"], 12 + "properties": { 13 + "subjectDid": { 14 + "type": "string", 15 + "description": "The DID of the OpnShelf user being followed" 16 + }, 17 + "createdAt": { 18 + "type": "string", 19 + "format": "datetime", 20 + "description": "When the follow record was created" 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+232 -232
packages/api/src/generated/@tanstack/react-query.gen.ts
··· 1032 1032 queryKey: listsControllerGetListsForMovieQueryKey(options) 1033 1033 }); 1034 1034 1035 + export const socialControllerSearchPeopleQueryKey = (options: Options<SocialControllerSearchPeopleData>) => createQueryKey('socialControllerSearchPeople', options); 1036 + 1037 + /** 1038 + * Search OpnShelf people by handle or display name 1039 + */ 1040 + export const socialControllerSearchPeopleOptions = (options: Options<SocialControllerSearchPeopleData>) => queryOptions<SocialControllerSearchPeopleResponse, DefaultError, SocialControllerSearchPeopleResponse, ReturnType<typeof socialControllerSearchPeopleQueryKey>>({ 1041 + queryFn: async ({ queryKey, signal }) => { 1042 + const { data } = await socialControllerSearchPeople({ 1043 + ...options, 1044 + ...queryKey[0], 1045 + signal, 1046 + throwOnError: true 1047 + }); 1048 + return data; 1049 + }, 1050 + queryKey: socialControllerSearchPeopleQueryKey(options) 1051 + }); 1052 + 1053 + export const socialControllerSearchPeopleInfiniteQueryKey = (options: Options<SocialControllerSearchPeopleData>): QueryKey<Options<SocialControllerSearchPeopleData>> => createQueryKey('socialControllerSearchPeople', options, true); 1054 + 1055 + /** 1056 + * Search OpnShelf people by handle or display name 1057 + */ 1058 + export const socialControllerSearchPeopleInfiniteOptions = (options: Options<SocialControllerSearchPeopleData>) => infiniteQueryOptions<SocialControllerSearchPeopleResponse, DefaultError, InfiniteData<SocialControllerSearchPeopleResponse>, QueryKey<Options<SocialControllerSearchPeopleData>>, number | Pick<QueryKey<Options<SocialControllerSearchPeopleData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1059 + // @ts-ignore 1060 + { 1061 + queryFn: async ({ pageParam, queryKey, signal }) => { 1062 + // @ts-ignore 1063 + const page: Pick<QueryKey<Options<SocialControllerSearchPeopleData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1064 + query: { 1065 + page: pageParam 1066 + } 1067 + }; 1068 + const params = createInfiniteParams(queryKey, page); 1069 + const { data } = await socialControllerSearchPeople({ 1070 + ...options, 1071 + ...params, 1072 + signal, 1073 + throwOnError: true 1074 + }); 1075 + return data; 1076 + }, 1077 + queryKey: socialControllerSearchPeopleInfiniteQueryKey(options) 1078 + }); 1079 + 1080 + /** 1081 + * Unfollow an OpnShelf user 1082 + */ 1083 + export const socialControllerUnfollowMutation = (options?: Partial<Options<SocialControllerUnfollowData>>): UseMutationOptions<SocialControllerUnfollowResponse, DefaultError, Options<SocialControllerUnfollowData>> => { 1084 + const mutationOptions: UseMutationOptions<SocialControllerUnfollowResponse, DefaultError, Options<SocialControllerUnfollowData>> = { 1085 + mutationFn: async (fnOptions) => { 1086 + const { data } = await socialControllerUnfollow({ 1087 + ...options, 1088 + ...fnOptions, 1089 + throwOnError: true 1090 + }); 1091 + return data; 1092 + } 1093 + }; 1094 + return mutationOptions; 1095 + }; 1096 + 1097 + /** 1098 + * Follow an OpnShelf user 1099 + */ 1100 + export const socialControllerFollowMutation = (options?: Partial<Options<SocialControllerFollowData>>): UseMutationOptions<SocialControllerFollowResponse, DefaultError, Options<SocialControllerFollowData>> => { 1101 + const mutationOptions: UseMutationOptions<SocialControllerFollowResponse, DefaultError, Options<SocialControllerFollowData>> = { 1102 + mutationFn: async (fnOptions) => { 1103 + const { data } = await socialControllerFollow({ 1104 + ...options, 1105 + ...fnOptions, 1106 + throwOnError: true 1107 + }); 1108 + return data; 1109 + } 1110 + }; 1111 + return mutationOptions; 1112 + }; 1113 + 1114 + export const socialControllerGetRelationshipQueryKey = (options: Options<SocialControllerGetRelationshipData>) => createQueryKey('socialControllerGetRelationship', options); 1115 + 1116 + /** 1117 + * Get the viewer's relationship to a user 1118 + */ 1119 + export const socialControllerGetRelationshipOptions = (options: Options<SocialControllerGetRelationshipData>) => queryOptions<SocialControllerGetRelationshipResponse, DefaultError, SocialControllerGetRelationshipResponse, ReturnType<typeof socialControllerGetRelationshipQueryKey>>({ 1120 + queryFn: async ({ queryKey, signal }) => { 1121 + const { data } = await socialControllerGetRelationship({ 1122 + ...options, 1123 + ...queryKey[0], 1124 + signal, 1125 + throwOnError: true 1126 + }); 1127 + return data; 1128 + }, 1129 + queryKey: socialControllerGetRelationshipQueryKey(options) 1130 + }); 1131 + 1132 + export const socialControllerGetFollowersQueryKey = (options: Options<SocialControllerGetFollowersData>) => createQueryKey('socialControllerGetFollowers', options); 1133 + 1134 + /** 1135 + * Get followers for a public profile 1136 + */ 1137 + export const socialControllerGetFollowersOptions = (options: Options<SocialControllerGetFollowersData>) => queryOptions<SocialControllerGetFollowersResponse, DefaultError, SocialControllerGetFollowersResponse, ReturnType<typeof socialControllerGetFollowersQueryKey>>({ 1138 + queryFn: async ({ queryKey, signal }) => { 1139 + const { data } = await socialControllerGetFollowers({ 1140 + ...options, 1141 + ...queryKey[0], 1142 + signal, 1143 + throwOnError: true 1144 + }); 1145 + return data; 1146 + }, 1147 + queryKey: socialControllerGetFollowersQueryKey(options) 1148 + }); 1149 + 1150 + export const socialControllerGetFollowersInfiniteQueryKey = (options: Options<SocialControllerGetFollowersData>): QueryKey<Options<SocialControllerGetFollowersData>> => createQueryKey('socialControllerGetFollowers', options, true); 1151 + 1152 + /** 1153 + * Get followers for a public profile 1154 + */ 1155 + export const socialControllerGetFollowersInfiniteOptions = (options: Options<SocialControllerGetFollowersData>) => infiniteQueryOptions<SocialControllerGetFollowersResponse, DefaultError, InfiniteData<SocialControllerGetFollowersResponse>, QueryKey<Options<SocialControllerGetFollowersData>>, number | Pick<QueryKey<Options<SocialControllerGetFollowersData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1156 + // @ts-ignore 1157 + { 1158 + queryFn: async ({ pageParam, queryKey, signal }) => { 1159 + // @ts-ignore 1160 + const page: Pick<QueryKey<Options<SocialControllerGetFollowersData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1161 + query: { 1162 + page: pageParam 1163 + } 1164 + }; 1165 + const params = createInfiniteParams(queryKey, page); 1166 + const { data } = await socialControllerGetFollowers({ 1167 + ...options, 1168 + ...params, 1169 + signal, 1170 + throwOnError: true 1171 + }); 1172 + return data; 1173 + }, 1174 + queryKey: socialControllerGetFollowersInfiniteQueryKey(options) 1175 + }); 1176 + 1177 + export const socialControllerGetFollowingQueryKey = (options: Options<SocialControllerGetFollowingData>) => createQueryKey('socialControllerGetFollowing', options); 1178 + 1179 + /** 1180 + * Get following for a public profile 1181 + */ 1182 + export const socialControllerGetFollowingOptions = (options: Options<SocialControllerGetFollowingData>) => queryOptions<SocialControllerGetFollowingResponse, DefaultError, SocialControllerGetFollowingResponse, ReturnType<typeof socialControllerGetFollowingQueryKey>>({ 1183 + queryFn: async ({ queryKey, signal }) => { 1184 + const { data } = await socialControllerGetFollowing({ 1185 + ...options, 1186 + ...queryKey[0], 1187 + signal, 1188 + throwOnError: true 1189 + }); 1190 + return data; 1191 + }, 1192 + queryKey: socialControllerGetFollowingQueryKey(options) 1193 + }); 1194 + 1195 + export const socialControllerGetFollowingInfiniteQueryKey = (options: Options<SocialControllerGetFollowingData>): QueryKey<Options<SocialControllerGetFollowingData>> => createQueryKey('socialControllerGetFollowing', options, true); 1196 + 1197 + /** 1198 + * Get following for a public profile 1199 + */ 1200 + export const socialControllerGetFollowingInfiniteOptions = (options: Options<SocialControllerGetFollowingData>) => infiniteQueryOptions<SocialControllerGetFollowingResponse, DefaultError, InfiniteData<SocialControllerGetFollowingResponse>, QueryKey<Options<SocialControllerGetFollowingData>>, number | Pick<QueryKey<Options<SocialControllerGetFollowingData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1201 + // @ts-ignore 1202 + { 1203 + queryFn: async ({ pageParam, queryKey, signal }) => { 1204 + // @ts-ignore 1205 + const page: Pick<QueryKey<Options<SocialControllerGetFollowingData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1206 + query: { 1207 + page: pageParam 1208 + } 1209 + }; 1210 + const params = createInfiniteParams(queryKey, page); 1211 + const { data } = await socialControllerGetFollowing({ 1212 + ...options, 1213 + ...params, 1214 + signal, 1215 + throwOnError: true 1216 + }); 1217 + return data; 1218 + }, 1219 + queryKey: socialControllerGetFollowingInfiniteQueryKey(options) 1220 + }); 1221 + 1222 + export const socialControllerGetFeedQueryKey = (options?: Options<SocialControllerGetFeedData>) => createQueryKey('socialControllerGetFeed', options); 1223 + 1224 + /** 1225 + * Get recent watched activity from followed users 1226 + */ 1227 + export const socialControllerGetFeedOptions = (options?: Options<SocialControllerGetFeedData>) => queryOptions<SocialControllerGetFeedResponse, DefaultError, SocialControllerGetFeedResponse, ReturnType<typeof socialControllerGetFeedQueryKey>>({ 1228 + queryFn: async ({ queryKey, signal }) => { 1229 + const { data } = await socialControllerGetFeed({ 1230 + ...options, 1231 + ...queryKey[0], 1232 + signal, 1233 + throwOnError: true 1234 + }); 1235 + return data; 1236 + }, 1237 + queryKey: socialControllerGetFeedQueryKey(options) 1238 + }); 1239 + 1240 + export const socialControllerGetFeedInfiniteQueryKey = (options?: Options<SocialControllerGetFeedData>): QueryKey<Options<SocialControllerGetFeedData>> => createQueryKey('socialControllerGetFeed', options, true); 1241 + 1242 + /** 1243 + * Get recent watched activity from followed users 1244 + */ 1245 + export const socialControllerGetFeedInfiniteOptions = (options?: Options<SocialControllerGetFeedData>) => infiniteQueryOptions<SocialControllerGetFeedResponse, DefaultError, InfiniteData<SocialControllerGetFeedResponse>, QueryKey<Options<SocialControllerGetFeedData>>, number | Pick<QueryKey<Options<SocialControllerGetFeedData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1246 + // @ts-ignore 1247 + { 1248 + queryFn: async ({ pageParam, queryKey, signal }) => { 1249 + // @ts-ignore 1250 + const page: Pick<QueryKey<Options<SocialControllerGetFeedData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1251 + query: { 1252 + page: pageParam 1253 + } 1254 + }; 1255 + const params = createInfiniteParams(queryKey, page); 1256 + const { data } = await socialControllerGetFeed({ 1257 + ...options, 1258 + ...params, 1259 + signal, 1260 + throwOnError: true 1261 + }); 1262 + return data; 1263 + }, 1264 + queryKey: socialControllerGetFeedInfiniteQueryKey(options) 1265 + }); 1266 + 1035 1267 export const usersControllerGetPublicProfileQueryKey = (options: Options<UsersControllerGetPublicProfileData>) => createQueryKey('usersControllerGetPublicProfile', options); 1036 1268 1037 1269 /** ··· 1339 1571 }, 1340 1572 queryKey: searchControllerDiscoverAllInfiniteQueryKey(options) 1341 1573 }); 1342 - 1343 - export const socialControllerSearchPeopleQueryKey = (options: Options<SocialControllerSearchPeopleData>) => createQueryKey('socialControllerSearchPeople', options); 1344 - 1345 - /** 1346 - * Search OpnShelf people by handle or display name 1347 - */ 1348 - export const socialControllerSearchPeopleOptions = (options: Options<SocialControllerSearchPeopleData>) => queryOptions<SocialControllerSearchPeopleResponse, DefaultError, SocialControllerSearchPeopleResponse, ReturnType<typeof socialControllerSearchPeopleQueryKey>>({ 1349 - queryFn: async ({ queryKey, signal }) => { 1350 - const { data } = await socialControllerSearchPeople({ 1351 - ...options, 1352 - ...queryKey[0], 1353 - signal, 1354 - throwOnError: true 1355 - }); 1356 - return data; 1357 - }, 1358 - queryKey: socialControllerSearchPeopleQueryKey(options) 1359 - }); 1360 - 1361 - export const socialControllerSearchPeopleInfiniteQueryKey = (options: Options<SocialControllerSearchPeopleData>): QueryKey<Options<SocialControllerSearchPeopleData>> => createQueryKey('socialControllerSearchPeople', options, true); 1362 - 1363 - /** 1364 - * Search OpnShelf people by handle or display name 1365 - */ 1366 - export const socialControllerSearchPeopleInfiniteOptions = (options: Options<SocialControllerSearchPeopleData>) => infiniteQueryOptions<SocialControllerSearchPeopleResponse, DefaultError, InfiniteData<SocialControllerSearchPeopleResponse>, QueryKey<Options<SocialControllerSearchPeopleData>>, number | Pick<QueryKey<Options<SocialControllerSearchPeopleData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1367 - // @ts-ignore 1368 - { 1369 - queryFn: async ({ pageParam, queryKey, signal }) => { 1370 - // @ts-ignore 1371 - const page: Pick<QueryKey<Options<SocialControllerSearchPeopleData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1372 - query: { 1373 - page: pageParam 1374 - } 1375 - }; 1376 - const params = createInfiniteParams(queryKey, page); 1377 - const { data } = await socialControllerSearchPeople({ 1378 - ...options, 1379 - ...params, 1380 - signal, 1381 - throwOnError: true 1382 - }); 1383 - return data; 1384 - }, 1385 - queryKey: socialControllerSearchPeopleInfiniteQueryKey(options) 1386 - }); 1387 - 1388 - /** 1389 - * Unfollow an OpnShelf user 1390 - */ 1391 - export const socialControllerUnfollowMutation = (options?: Partial<Options<SocialControllerUnfollowData>>): UseMutationOptions<SocialControllerUnfollowResponse, DefaultError, Options<SocialControllerUnfollowData>> => { 1392 - const mutationOptions: UseMutationOptions<SocialControllerUnfollowResponse, DefaultError, Options<SocialControllerUnfollowData>> = { 1393 - mutationFn: async (fnOptions) => { 1394 - const { data } = await socialControllerUnfollow({ 1395 - ...options, 1396 - ...fnOptions, 1397 - throwOnError: true 1398 - }); 1399 - return data; 1400 - } 1401 - }; 1402 - return mutationOptions; 1403 - }; 1404 - 1405 - /** 1406 - * Follow an OpnShelf user 1407 - */ 1408 - export const socialControllerFollowMutation = (options?: Partial<Options<SocialControllerFollowData>>): UseMutationOptions<SocialControllerFollowResponse, DefaultError, Options<SocialControllerFollowData>> => { 1409 - const mutationOptions: UseMutationOptions<SocialControllerFollowResponse, DefaultError, Options<SocialControllerFollowData>> = { 1410 - mutationFn: async (fnOptions) => { 1411 - const { data } = await socialControllerFollow({ 1412 - ...options, 1413 - ...fnOptions, 1414 - throwOnError: true 1415 - }); 1416 - return data; 1417 - } 1418 - }; 1419 - return mutationOptions; 1420 - }; 1421 - 1422 - export const socialControllerGetRelationshipQueryKey = (options: Options<SocialControllerGetRelationshipData>) => createQueryKey('socialControllerGetRelationship', options); 1423 - 1424 - /** 1425 - * Get the viewer's relationship to a user 1426 - */ 1427 - export const socialControllerGetRelationshipOptions = (options: Options<SocialControllerGetRelationshipData>) => queryOptions<SocialControllerGetRelationshipResponse, DefaultError, SocialControllerGetRelationshipResponse, ReturnType<typeof socialControllerGetRelationshipQueryKey>>({ 1428 - queryFn: async ({ queryKey, signal }) => { 1429 - const { data } = await socialControllerGetRelationship({ 1430 - ...options, 1431 - ...queryKey[0], 1432 - signal, 1433 - throwOnError: true 1434 - }); 1435 - return data; 1436 - }, 1437 - queryKey: socialControllerGetRelationshipQueryKey(options) 1438 - }); 1439 - 1440 - export const socialControllerGetFollowersQueryKey = (options: Options<SocialControllerGetFollowersData>) => createQueryKey('socialControllerGetFollowers', options); 1441 - 1442 - /** 1443 - * Get followers for a public profile 1444 - */ 1445 - export const socialControllerGetFollowersOptions = (options: Options<SocialControllerGetFollowersData>) => queryOptions<SocialControllerGetFollowersResponse, DefaultError, SocialControllerGetFollowersResponse, ReturnType<typeof socialControllerGetFollowersQueryKey>>({ 1446 - queryFn: async ({ queryKey, signal }) => { 1447 - const { data } = await socialControllerGetFollowers({ 1448 - ...options, 1449 - ...queryKey[0], 1450 - signal, 1451 - throwOnError: true 1452 - }); 1453 - return data; 1454 - }, 1455 - queryKey: socialControllerGetFollowersQueryKey(options) 1456 - }); 1457 - 1458 - export const socialControllerGetFollowersInfiniteQueryKey = (options: Options<SocialControllerGetFollowersData>): QueryKey<Options<SocialControllerGetFollowersData>> => createQueryKey('socialControllerGetFollowers', options, true); 1459 - 1460 - /** 1461 - * Get followers for a public profile 1462 - */ 1463 - export const socialControllerGetFollowersInfiniteOptions = (options: Options<SocialControllerGetFollowersData>) => infiniteQueryOptions<SocialControllerGetFollowersResponse, DefaultError, InfiniteData<SocialControllerGetFollowersResponse>, QueryKey<Options<SocialControllerGetFollowersData>>, number | Pick<QueryKey<Options<SocialControllerGetFollowersData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1464 - // @ts-ignore 1465 - { 1466 - queryFn: async ({ pageParam, queryKey, signal }) => { 1467 - // @ts-ignore 1468 - const page: Pick<QueryKey<Options<SocialControllerGetFollowersData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1469 - query: { 1470 - page: pageParam 1471 - } 1472 - }; 1473 - const params = createInfiniteParams(queryKey, page); 1474 - const { data } = await socialControllerGetFollowers({ 1475 - ...options, 1476 - ...params, 1477 - signal, 1478 - throwOnError: true 1479 - }); 1480 - return data; 1481 - }, 1482 - queryKey: socialControllerGetFollowersInfiniteQueryKey(options) 1483 - }); 1484 - 1485 - export const socialControllerGetFollowingQueryKey = (options: Options<SocialControllerGetFollowingData>) => createQueryKey('socialControllerGetFollowing', options); 1486 - 1487 - /** 1488 - * Get following for a public profile 1489 - */ 1490 - export const socialControllerGetFollowingOptions = (options: Options<SocialControllerGetFollowingData>) => queryOptions<SocialControllerGetFollowingResponse, DefaultError, SocialControllerGetFollowingResponse, ReturnType<typeof socialControllerGetFollowingQueryKey>>({ 1491 - queryFn: async ({ queryKey, signal }) => { 1492 - const { data } = await socialControllerGetFollowing({ 1493 - ...options, 1494 - ...queryKey[0], 1495 - signal, 1496 - throwOnError: true 1497 - }); 1498 - return data; 1499 - }, 1500 - queryKey: socialControllerGetFollowingQueryKey(options) 1501 - }); 1502 - 1503 - export const socialControllerGetFollowingInfiniteQueryKey = (options: Options<SocialControllerGetFollowingData>): QueryKey<Options<SocialControllerGetFollowingData>> => createQueryKey('socialControllerGetFollowing', options, true); 1504 - 1505 - /** 1506 - * Get following for a public profile 1507 - */ 1508 - export const socialControllerGetFollowingInfiniteOptions = (options: Options<SocialControllerGetFollowingData>) => infiniteQueryOptions<SocialControllerGetFollowingResponse, DefaultError, InfiniteData<SocialControllerGetFollowingResponse>, QueryKey<Options<SocialControllerGetFollowingData>>, number | Pick<QueryKey<Options<SocialControllerGetFollowingData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1509 - // @ts-ignore 1510 - { 1511 - queryFn: async ({ pageParam, queryKey, signal }) => { 1512 - // @ts-ignore 1513 - const page: Pick<QueryKey<Options<SocialControllerGetFollowingData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1514 - query: { 1515 - page: pageParam 1516 - } 1517 - }; 1518 - const params = createInfiniteParams(queryKey, page); 1519 - const { data } = await socialControllerGetFollowing({ 1520 - ...options, 1521 - ...params, 1522 - signal, 1523 - throwOnError: true 1524 - }); 1525 - return data; 1526 - }, 1527 - queryKey: socialControllerGetFollowingInfiniteQueryKey(options) 1528 - }); 1529 - 1530 - export const socialControllerGetFeedQueryKey = (options?: Options<SocialControllerGetFeedData>) => createQueryKey('socialControllerGetFeed', options); 1531 - 1532 - /** 1533 - * Get recent watched activity from followed users 1534 - */ 1535 - export const socialControllerGetFeedOptions = (options?: Options<SocialControllerGetFeedData>) => queryOptions<SocialControllerGetFeedResponse, DefaultError, SocialControllerGetFeedResponse, ReturnType<typeof socialControllerGetFeedQueryKey>>({ 1536 - queryFn: async ({ queryKey, signal }) => { 1537 - const { data } = await socialControllerGetFeed({ 1538 - ...options, 1539 - ...queryKey[0], 1540 - signal, 1541 - throwOnError: true 1542 - }); 1543 - return data; 1544 - }, 1545 - queryKey: socialControllerGetFeedQueryKey(options) 1546 - }); 1547 - 1548 - export const socialControllerGetFeedInfiniteQueryKey = (options?: Options<SocialControllerGetFeedData>): QueryKey<Options<SocialControllerGetFeedData>> => createQueryKey('socialControllerGetFeed', options, true); 1549 - 1550 - /** 1551 - * Get recent watched activity from followed users 1552 - */ 1553 - export const socialControllerGetFeedInfiniteOptions = (options?: Options<SocialControllerGetFeedData>) => infiniteQueryOptions<SocialControllerGetFeedResponse, DefaultError, InfiniteData<SocialControllerGetFeedResponse>, QueryKey<Options<SocialControllerGetFeedData>>, number | Pick<QueryKey<Options<SocialControllerGetFeedData>>[0], 'body' | 'headers' | 'path' | 'query'>>( 1554 - // @ts-ignore 1555 - { 1556 - queryFn: async ({ pageParam, queryKey, signal }) => { 1557 - // @ts-ignore 1558 - const page: Pick<QueryKey<Options<SocialControllerGetFeedData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : { 1559 - query: { 1560 - page: pageParam 1561 - } 1562 - }; 1563 - const params = createInfiniteParams(queryKey, page); 1564 - const { data } = await socialControllerGetFeed({ 1565 - ...options, 1566 - ...params, 1567 - signal, 1568 - throwOnError: true 1569 - }); 1570 - return data; 1571 - }, 1572 - queryKey: socialControllerGetFeedInfiniteQueryKey(options) 1573 - });
+35 -35
packages/api/src/generated/sdk.gen.ts
··· 303 303 export const listsControllerGetListsForMovie = <ThrowOnError extends boolean = false>(options: Options<ListsControllerGetListsForMovieData, ThrowOnError>) => (options.client ?? client).get<ListsControllerGetListsForMovieResponses, unknown, ThrowOnError>({ url: '/lists/for-movie/{movieId}', ...options }); 304 304 305 305 /** 306 + * Search OpnShelf people by handle or display name 307 + */ 308 + export const socialControllerSearchPeople = <ThrowOnError extends boolean = false>(options: Options<SocialControllerSearchPeopleData, ThrowOnError>) => (options.client ?? client).get<SocialControllerSearchPeopleResponses, unknown, ThrowOnError>({ url: '/social/search', ...options }); 309 + 310 + /** 311 + * Unfollow an OpnShelf user 312 + */ 313 + export const socialControllerUnfollow = <ThrowOnError extends boolean = false>(options: Options<SocialControllerUnfollowData, ThrowOnError>) => (options.client ?? client).delete<SocialControllerUnfollowResponses, unknown, ThrowOnError>({ url: '/social/follows/{targetDid}', ...options }); 314 + 315 + /** 316 + * Follow an OpnShelf user 317 + */ 318 + export const socialControllerFollow = <ThrowOnError extends boolean = false>(options: Options<SocialControllerFollowData, ThrowOnError>) => (options.client ?? client).post<SocialControllerFollowResponses, unknown, ThrowOnError>({ url: '/social/follows/{targetDid}', ...options }); 319 + 320 + /** 321 + * Get the viewer's relationship to a user 322 + */ 323 + export const socialControllerGetRelationship = <ThrowOnError extends boolean = false>(options: Options<SocialControllerGetRelationshipData, ThrowOnError>) => (options.client ?? client).get<SocialControllerGetRelationshipResponses, unknown, ThrowOnError>({ url: '/social/relationship/{targetDid}', ...options }); 324 + 325 + /** 326 + * Get followers for a public profile 327 + */ 328 + export const socialControllerGetFollowers = <ThrowOnError extends boolean = false>(options: Options<SocialControllerGetFollowersData, ThrowOnError>) => (options.client ?? client).get<SocialControllerGetFollowersResponses, unknown, ThrowOnError>({ url: '/social/profiles/{handle}/followers', ...options }); 329 + 330 + /** 331 + * Get following for a public profile 332 + */ 333 + export const socialControllerGetFollowing = <ThrowOnError extends boolean = false>(options: Options<SocialControllerGetFollowingData, ThrowOnError>) => (options.client ?? client).get<SocialControllerGetFollowingResponses, unknown, ThrowOnError>({ url: '/social/profiles/{handle}/following', ...options }); 334 + 335 + /** 336 + * Get recent watched activity from followed users 337 + */ 338 + export const socialControllerGetFeed = <ThrowOnError extends boolean = false>(options?: Options<SocialControllerGetFeedData, ThrowOnError>) => (options?.client ?? client).get<SocialControllerGetFeedResponses, unknown, ThrowOnError>({ url: '/social/feed', ...options }); 339 + 340 + /** 306 341 * Get a public user profile by handle 307 342 */ 308 343 export const usersControllerGetPublicProfile = <ThrowOnError extends boolean = false>(options: Options<UsersControllerGetPublicProfileData, ThrowOnError>) => (options.client ?? client).get<UsersControllerGetPublicProfileResponses, UsersControllerGetPublicProfileErrors, ThrowOnError>({ url: '/users/{handle}/profile', ...options }); ··· 401 436 * Discover popular movies and shows from TMDB 402 437 */ 403 438 export const searchControllerDiscoverAll = <ThrowOnError extends boolean = false>(options?: Options<SearchControllerDiscoverAllData, ThrowOnError>) => (options?.client ?? client).get<SearchControllerDiscoverAllResponses, unknown, ThrowOnError>({ url: '/search/discover', ...options }); 404 - 405 - /** 406 - * Search OpnShelf people by handle or display name 407 - */ 408 - export const socialControllerSearchPeople = <ThrowOnError extends boolean = false>(options: Options<SocialControllerSearchPeopleData, ThrowOnError>) => (options.client ?? client).get<SocialControllerSearchPeopleResponses, unknown, ThrowOnError>({ url: '/social/search', ...options }); 409 - 410 - /** 411 - * Unfollow an OpnShelf user 412 - */ 413 - export const socialControllerUnfollow = <ThrowOnError extends boolean = false>(options: Options<SocialControllerUnfollowData, ThrowOnError>) => (options.client ?? client).delete<SocialControllerUnfollowResponses, unknown, ThrowOnError>({ url: '/social/follows/{targetDid}', ...options }); 414 - 415 - /** 416 - * Follow an OpnShelf user 417 - */ 418 - export const socialControllerFollow = <ThrowOnError extends boolean = false>(options: Options<SocialControllerFollowData, ThrowOnError>) => (options.client ?? client).post<SocialControllerFollowResponses, unknown, ThrowOnError>({ url: '/social/follows/{targetDid}', ...options }); 419 - 420 - /** 421 - * Get the viewer's relationship to a user 422 - */ 423 - export const socialControllerGetRelationship = <ThrowOnError extends boolean = false>(options: Options<SocialControllerGetRelationshipData, ThrowOnError>) => (options.client ?? client).get<SocialControllerGetRelationshipResponses, unknown, ThrowOnError>({ url: '/social/relationship/{targetDid}', ...options }); 424 - 425 - /** 426 - * Get followers for a public profile 427 - */ 428 - export const socialControllerGetFollowers = <ThrowOnError extends boolean = false>(options: Options<SocialControllerGetFollowersData, ThrowOnError>) => (options.client ?? client).get<SocialControllerGetFollowersResponses, unknown, ThrowOnError>({ url: '/social/profiles/{handle}/followers', ...options }); 429 - 430 - /** 431 - * Get following for a public profile 432 - */ 433 - export const socialControllerGetFollowing = <ThrowOnError extends boolean = false>(options: Options<SocialControllerGetFollowingData, ThrowOnError>) => (options.client ?? client).get<SocialControllerGetFollowingResponses, unknown, ThrowOnError>({ url: '/social/profiles/{handle}/following', ...options }); 434 - 435 - /** 436 - * Get recent watched activity from followed users 437 - */ 438 - export const socialControllerGetFeed = <ThrowOnError extends boolean = false>(options?: Options<SocialControllerGetFeedData, ThrowOnError>) => (options?.client ?? client).get<SocialControllerGetFeedResponses, unknown, ThrowOnError>({ url: '/social/feed', ...options });
+225 -221
packages/api/src/generated/types.gen.ts
··· 571 571 isInList: boolean; 572 572 }; 573 573 574 + export type SocialUserCardDto = { 575 + did: string; 576 + handle: string; 577 + displayName?: { 578 + [key: string]: unknown; 579 + } | null; 580 + avatar?: { 581 + [key: string]: unknown; 582 + } | null; 583 + followersCount: number; 584 + followingCount: number; 585 + isFollowing: boolean; 586 + isFollowedBy: boolean; 587 + }; 588 + 589 + export type PaginatedSocialUsersDto = { 590 + items: Array<SocialUserCardDto>; 591 + page: number; 592 + pageSize: number; 593 + total: number; 594 + totalPages: number; 595 + hasNextPage: boolean; 596 + hasPreviousPage: boolean; 597 + }; 598 + 599 + export type UserRelationshipDto = { 600 + targetDid: string; 601 + isFollowing: boolean; 602 + isFollowedBy: boolean; 603 + canFollow: boolean; 604 + }; 605 + 606 + export type SocialActorDto = { 607 + did: string; 608 + handle: string; 609 + displayName?: { 610 + [key: string]: unknown; 611 + } | null; 612 + avatar?: { 613 + [key: string]: unknown; 614 + } | null; 615 + followersCount: number; 616 + followingCount: number; 617 + }; 618 + 619 + export type FollowedActivityItemDto = { 620 + actor: SocialActorDto; 621 + id: string; 622 + type: 'movie' | 'episode'; 623 + activityAt: string; 624 + movieId?: string; 625 + title?: string; 626 + showId?: string; 627 + showTitle?: string; 628 + seasonNumber?: number; 629 + episodeNumber?: number; 630 + posterPath?: string; 631 + backdropPath?: string; 632 + releaseYear?: number; 633 + firstAirYear?: number; 634 + overview?: string; 635 + colors?: MovieColorsDto; 636 + watchedDate?: string; 637 + createdAt: string; 638 + }; 639 + 640 + export type FollowedActivityFeedDto = { 641 + items: Array<FollowedActivityItemDto>; 642 + page: number; 643 + pageSize: number; 644 + total: number; 645 + totalPages: number; 646 + hasNextPage: boolean; 647 + hasPreviousPage: boolean; 648 + }; 649 + 574 650 export type PublicUserProfileDto = { 575 651 /** 576 652 * Stable DID for the user ··· 648 724 649 725 export type DeleteUserAccountDto = { 650 726 /** 651 - * Whether to delete the user's watch history from their PDS. If false, the data remains on their PDS. 727 + * Whether to delete the user's OpnShelf data from their PDS, including watch history, follows, lists, and list items. If false, the data remains on their PDS. 652 728 */ 653 729 deletePDSData: boolean; 654 730 }; ··· 909 985 results: Array<UnifiedSearchResultDto>; 910 986 total_results: number; 911 987 page: number; 912 - }; 913 - 914 - export type SocialUserCardDto = { 915 - did: string; 916 - handle: string; 917 - displayName?: { 918 - [key: string]: unknown; 919 - } | null; 920 - avatar?: { 921 - [key: string]: unknown; 922 - } | null; 923 - followersCount: number; 924 - followingCount: number; 925 - isFollowing: boolean; 926 - isFollowedBy: boolean; 927 - }; 928 - 929 - export type PaginatedSocialUsersDto = { 930 - items: Array<SocialUserCardDto>; 931 - page: number; 932 - pageSize: number; 933 - total: number; 934 - totalPages: number; 935 - hasNextPage: boolean; 936 - hasPreviousPage: boolean; 937 - }; 938 - 939 - export type UserRelationshipDto = { 940 - targetDid: string; 941 - isFollowing: boolean; 942 - isFollowedBy: boolean; 943 - canFollow: boolean; 944 - }; 945 - 946 - export type SocialActorDto = { 947 - did: string; 948 - handle: string; 949 - displayName?: { 950 - [key: string]: unknown; 951 - } | null; 952 - avatar?: { 953 - [key: string]: unknown; 954 - } | null; 955 - followersCount: number; 956 - followingCount: number; 957 - }; 958 - 959 - export type FollowedActivityItemDto = { 960 - actor: SocialActorDto; 961 - id: string; 962 - type: 'movie' | 'episode'; 963 - activityAt: string; 964 - movieId?: string; 965 - title?: string; 966 - showId?: string; 967 - showTitle?: string; 968 - seasonNumber?: number; 969 - episodeNumber?: number; 970 - posterPath?: string; 971 - backdropPath?: string; 972 - releaseYear?: number; 973 - firstAirYear?: number; 974 - overview?: string; 975 - colors?: MovieColorsDto; 976 - watchedDate?: string; 977 - createdAt: string; 978 - }; 979 - 980 - export type FollowedActivityFeedDto = { 981 - items: Array<FollowedActivityItemDto>; 982 - page: number; 983 - pageSize: number; 984 - total: number; 985 - totalPages: number; 986 - hasNextPage: boolean; 987 - hasPreviousPage: boolean; 988 988 }; 989 989 990 990 export type MoviesControllerSearchMoviesData = { ··· 2018 2018 200: unknown; 2019 2019 }; 2020 2020 2021 + export type SocialControllerSearchPeopleData = { 2022 + body?: never; 2023 + path?: never; 2024 + query: { 2025 + /** 2026 + * Page number to return 2027 + */ 2028 + page?: number; 2029 + /** 2030 + * Number of items to return per page 2031 + */ 2032 + pageSize?: number; 2033 + /** 2034 + * Search term for handle or display name 2035 + */ 2036 + q: string; 2037 + }; 2038 + url: '/social/search'; 2039 + }; 2040 + 2041 + export type SocialControllerSearchPeopleResponses = { 2042 + 200: PaginatedSocialUsersDto; 2043 + }; 2044 + 2045 + export type SocialControllerSearchPeopleResponse = SocialControllerSearchPeopleResponses[keyof SocialControllerSearchPeopleResponses]; 2046 + 2047 + export type SocialControllerUnfollowData = { 2048 + body?: never; 2049 + path: { 2050 + targetDid: string; 2051 + }; 2052 + query?: never; 2053 + url: '/social/follows/{targetDid}'; 2054 + }; 2055 + 2056 + export type SocialControllerUnfollowResponses = { 2057 + /** 2058 + * Relationship removed 2059 + */ 2060 + 204: void; 2061 + }; 2062 + 2063 + export type SocialControllerUnfollowResponse = SocialControllerUnfollowResponses[keyof SocialControllerUnfollowResponses]; 2064 + 2065 + export type SocialControllerFollowData = { 2066 + body?: never; 2067 + path: { 2068 + targetDid: string; 2069 + }; 2070 + query?: never; 2071 + url: '/social/follows/{targetDid}'; 2072 + }; 2073 + 2074 + export type SocialControllerFollowResponses = { 2075 + 200: UserRelationshipDto; 2076 + }; 2077 + 2078 + export type SocialControllerFollowResponse = SocialControllerFollowResponses[keyof SocialControllerFollowResponses]; 2079 + 2080 + export type SocialControllerGetRelationshipData = { 2081 + body?: never; 2082 + path: { 2083 + targetDid: string; 2084 + }; 2085 + query?: never; 2086 + url: '/social/relationship/{targetDid}'; 2087 + }; 2088 + 2089 + export type SocialControllerGetRelationshipResponses = { 2090 + 200: UserRelationshipDto; 2091 + }; 2092 + 2093 + export type SocialControllerGetRelationshipResponse = SocialControllerGetRelationshipResponses[keyof SocialControllerGetRelationshipResponses]; 2094 + 2095 + export type SocialControllerGetFollowersData = { 2096 + body?: never; 2097 + path: { 2098 + handle: string; 2099 + }; 2100 + query?: { 2101 + /** 2102 + * Page number to return 2103 + */ 2104 + page?: number; 2105 + /** 2106 + * Number of items to return per page 2107 + */ 2108 + pageSize?: number; 2109 + }; 2110 + url: '/social/profiles/{handle}/followers'; 2111 + }; 2112 + 2113 + export type SocialControllerGetFollowersResponses = { 2114 + 200: PaginatedSocialUsersDto; 2115 + }; 2116 + 2117 + export type SocialControllerGetFollowersResponse = SocialControllerGetFollowersResponses[keyof SocialControllerGetFollowersResponses]; 2118 + 2119 + export type SocialControllerGetFollowingData = { 2120 + body?: never; 2121 + path: { 2122 + handle: string; 2123 + }; 2124 + query?: { 2125 + /** 2126 + * Page number to return 2127 + */ 2128 + page?: number; 2129 + /** 2130 + * Number of items to return per page 2131 + */ 2132 + pageSize?: number; 2133 + }; 2134 + url: '/social/profiles/{handle}/following'; 2135 + }; 2136 + 2137 + export type SocialControllerGetFollowingResponses = { 2138 + 200: PaginatedSocialUsersDto; 2139 + }; 2140 + 2141 + export type SocialControllerGetFollowingResponse = SocialControllerGetFollowingResponses[keyof SocialControllerGetFollowingResponses]; 2142 + 2143 + export type SocialControllerGetFeedData = { 2144 + body?: never; 2145 + path?: never; 2146 + query?: { 2147 + /** 2148 + * Page number to return 2149 + */ 2150 + page?: number; 2151 + /** 2152 + * Number of items to return per page 2153 + */ 2154 + pageSize?: number; 2155 + }; 2156 + url: '/social/feed'; 2157 + }; 2158 + 2159 + export type SocialControllerGetFeedResponses = { 2160 + 200: FollowedActivityFeedDto; 2161 + }; 2162 + 2163 + export type SocialControllerGetFeedResponse = SocialControllerGetFeedResponses[keyof SocialControllerGetFeedResponses]; 2164 + 2021 2165 export type UsersControllerGetPublicProfileData = { 2022 2166 body?: never; 2023 2167 path: { ··· 2112 2256 * Not authenticated 2113 2257 */ 2114 2258 401: unknown; 2259 + /** 2260 + * Failed to delete OpnShelf data from the user's PDS, so the account was not deleted 2261 + */ 2262 + 502: unknown; 2115 2263 }; 2116 2264 2117 2265 export type UsersControllerDeleteMyAccountResponses = { ··· 2289 2437 }; 2290 2438 2291 2439 export type SearchControllerDiscoverAllResponse = SearchControllerDiscoverAllResponses[keyof SearchControllerDiscoverAllResponses]; 2292 - 2293 - export type SocialControllerSearchPeopleData = { 2294 - body?: never; 2295 - path?: never; 2296 - query: { 2297 - /** 2298 - * Page number to return 2299 - */ 2300 - page?: number; 2301 - /** 2302 - * Number of items to return per page 2303 - */ 2304 - pageSize?: number; 2305 - /** 2306 - * Search term for handle or display name 2307 - */ 2308 - q: string; 2309 - }; 2310 - url: '/social/search'; 2311 - }; 2312 - 2313 - export type SocialControllerSearchPeopleResponses = { 2314 - 200: PaginatedSocialUsersDto; 2315 - }; 2316 - 2317 - export type SocialControllerSearchPeopleResponse = SocialControllerSearchPeopleResponses[keyof SocialControllerSearchPeopleResponses]; 2318 - 2319 - export type SocialControllerUnfollowData = { 2320 - body?: never; 2321 - path: { 2322 - targetDid: string; 2323 - }; 2324 - query?: never; 2325 - url: '/social/follows/{targetDid}'; 2326 - }; 2327 - 2328 - export type SocialControllerUnfollowResponses = { 2329 - /** 2330 - * Relationship removed 2331 - */ 2332 - 204: void; 2333 - }; 2334 - 2335 - export type SocialControllerUnfollowResponse = SocialControllerUnfollowResponses[keyof SocialControllerUnfollowResponses]; 2336 - 2337 - export type SocialControllerFollowData = { 2338 - body?: never; 2339 - path: { 2340 - targetDid: string; 2341 - }; 2342 - query?: never; 2343 - url: '/social/follows/{targetDid}'; 2344 - }; 2345 - 2346 - export type SocialControllerFollowResponses = { 2347 - 200: UserRelationshipDto; 2348 - }; 2349 - 2350 - export type SocialControllerFollowResponse = SocialControllerFollowResponses[keyof SocialControllerFollowResponses]; 2351 - 2352 - export type SocialControllerGetRelationshipData = { 2353 - body?: never; 2354 - path: { 2355 - targetDid: string; 2356 - }; 2357 - query?: never; 2358 - url: '/social/relationship/{targetDid}'; 2359 - }; 2360 - 2361 - export type SocialControllerGetRelationshipResponses = { 2362 - 200: UserRelationshipDto; 2363 - }; 2364 - 2365 - export type SocialControllerGetRelationshipResponse = SocialControllerGetRelationshipResponses[keyof SocialControllerGetRelationshipResponses]; 2366 - 2367 - export type SocialControllerGetFollowersData = { 2368 - body?: never; 2369 - path: { 2370 - handle: string; 2371 - }; 2372 - query?: { 2373 - /** 2374 - * Page number to return 2375 - */ 2376 - page?: number; 2377 - /** 2378 - * Number of items to return per page 2379 - */ 2380 - pageSize?: number; 2381 - }; 2382 - url: '/social/profiles/{handle}/followers'; 2383 - }; 2384 - 2385 - export type SocialControllerGetFollowersResponses = { 2386 - 200: PaginatedSocialUsersDto; 2387 - }; 2388 - 2389 - export type SocialControllerGetFollowersResponse = SocialControllerGetFollowersResponses[keyof SocialControllerGetFollowersResponses]; 2390 - 2391 - export type SocialControllerGetFollowingData = { 2392 - body?: never; 2393 - path: { 2394 - handle: string; 2395 - }; 2396 - query?: { 2397 - /** 2398 - * Page number to return 2399 - */ 2400 - page?: number; 2401 - /** 2402 - * Number of items to return per page 2403 - */ 2404 - pageSize?: number; 2405 - }; 2406 - url: '/social/profiles/{handle}/following'; 2407 - }; 2408 - 2409 - export type SocialControllerGetFollowingResponses = { 2410 - 200: PaginatedSocialUsersDto; 2411 - }; 2412 - 2413 - export type SocialControllerGetFollowingResponse = SocialControllerGetFollowingResponses[keyof SocialControllerGetFollowingResponses]; 2414 - 2415 - export type SocialControllerGetFeedData = { 2416 - body?: never; 2417 - path?: never; 2418 - query?: { 2419 - /** 2420 - * Page number to return 2421 - */ 2422 - page?: number; 2423 - /** 2424 - * Number of items to return per page 2425 - */ 2426 - pageSize?: number; 2427 - }; 2428 - url: '/social/feed'; 2429 - }; 2430 - 2431 - export type SocialControllerGetFeedResponses = { 2432 - 200: FollowedActivityFeedDto; 2433 - }; 2434 - 2435 - export type SocialControllerGetFeedResponse = SocialControllerGetFeedResponses[keyof SocialControllerGetFeedResponses];