···253253 profileRecord, err := client.GetProfileRecord(ctx, did)
254254 if err != nil {
255255 slog.Warn("Failed to fetch profile record", "component", "appview/callback", "did", did, "error", err)
256256- // Still update user without avatar
257257- _ = db.UpsertUser(uiDatabase, &db.User{
258258- DID: did,
259259- Handle: handle,
260260- PDSEndpoint: pdsEndpoint,
261261- Avatar: "",
262262- LastSeen: time.Now(),
263263- })
264264- return nil // Non-fatal
256256+ // Continue without avatar - set profileRecord to nil to skip avatar extraction
257257+ profileRecord = nil
265258 }
266259267267- // Construct avatar URL from blob CID using imgs.blue CDN
268268- var avatarURL string
269269- if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
260260+ // Construct avatar URL from blob CID using imgs.blue CDN (if profile record was fetched successfully)
261261+ avatarURL := ""
262262+ if profileRecord != nil && profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
270263 avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
271264 slog.Debug("Constructed avatar URL", "component", "appview/callback", "avatar_url", avatarURL)
272265 }
273266274274- // Store user with avatar in database
275275- err = db.UpsertUser(uiDatabase, &db.User{
267267+ // Store user in database (with or without avatar)
268268+ err = db.UpsertUserIgnoreAvatar(uiDatabase, &db.User{
276269 DID: did,
277270 Handle: handle,
278271 PDSEndpoint: pdsEndpoint,
···284277 return nil // Non-fatal
285278 }
286279287287- slog.Debug("Stored user with avatar", "component", "appview/callback", "did", did)
280280+ slog.Debug("Stored user", "component", "appview/callback", "did", did, "has_avatar", avatarURL != "")
288281289282 // Migrate profile URL→DID if needed
290283 profile, err := storage.GetProfile(ctx, client)
+23
pkg/appview/db/queries.go
···351351 return err
352352}
353353354354+// UpsertUserIgnoreAvatar inserts or updates a user record, but preserves existing avatar on update
355355+// This is useful when avatar fetch fails, and we don't want to overwrite an existing avatar with empty string
356356+func UpsertUserIgnoreAvatar(db *sql.DB, user *User) error {
357357+ _, err := db.Exec(`
358358+ INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
359359+ VALUES (?, ?, ?, ?, ?)
360360+ ON CONFLICT(did) DO UPDATE SET
361361+ handle = excluded.handle,
362362+ pds_endpoint = excluded.pds_endpoint,
363363+ last_seen = excluded.last_seen
364364+ `, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
365365+ return err
366366+}
367367+368368+// UpdateUserLastSeen updates only the last_seen timestamp for a user
369369+// This is more efficient than UpsertUser when only updating activity timestamp
370370+func UpdateUserLastSeen(db *sql.DB, did string) error {
371371+ _, err := db.Exec(`
372372+ UPDATE users SET last_seen = ? WHERE did = ?
373373+ `, time.Now(), did)
374374+ return err
375375+}
376376+354377// GetManifestDigestsForDID returns all manifest digests for a DID
355378func GetManifestDigestsForDID(db *sql.DB, did string) ([]string, error) {
356379 rows, err := db.Query(`
+21-42
pkg/appview/jetstream/processor.go
···4848func (p *Processor) EnsureUser(ctx context.Context, did string) error {
4949 // Check cache first (if enabled)
5050 if p.useCache && p.userCache != nil {
5151- if user, ok := p.userCache.cache[did]; ok {
5252- // Update last seen
5353- user.LastSeen = time.Now()
5454- return db.UpsertUser(p.db, user)
5151+ if _, ok := p.userCache.cache[did]; ok {
5252+ // User in cache - just update last seen timestamp
5353+ return db.UpdateUserLastSeen(p.db, did)
5554 }
5655 } else if !p.useCache {
5756 // No cache - check if user already exists in DB
5857 existingUser, err := db.GetUserByDID(p.db, did)
5958 if err == nil && existingUser != nil {
6060- // Update last seen
6161- existingUser.LastSeen = time.Now()
6262- return db.UpsertUser(p.db, existingUser)
5959+ // User exists - just update last seen timestamp
6060+ return db.UpdateUserLastSeen(p.db, did)
6361 }
6462 }
65636664 // Resolve DID to get handle and PDS endpoint
6765 didParsed, err := syntax.ParseDID(did)
6866 if err != nil {
6969- // Fallback: use DID as handle
7070- user := &db.User{
7171- DID: did,
7272- Handle: did,
7373- PDSEndpoint: "https://bsky.social",
7474- LastSeen: time.Now(),
7575- }
7676- if p.useCache {
7777- p.userCache.cache[did] = user
7878- }
7979- return db.UpsertUser(p.db, user)
6767+ return fmt.Errorf("failed to parse DID: %w", err)
8068 }
81698270 ident, err := p.directory.LookupDID(ctx, didParsed)
8371 if err != nil {
8484- // Fallback: use DID as handle
8585- user := &db.User{
8686- DID: did,
8787- Handle: did,
8888- PDSEndpoint: "https://bsky.social",
8989- LastSeen: time.Now(),
9090- }
9191- if p.useCache {
9292- p.userCache.cache[did] = user
9393- }
9494- return db.UpsertUser(p.db, user)
7272+ return fmt.Errorf("failed to lookup DID: %w", err)
9573 }
96749775 resolvedDID := ident.DID.String()
9876 handle := ident.Handle.String()
9977 pdsEndpoint := ident.PDSEndpoint()
10078101101- // If handle is invalid or PDS is missing, use defaults
7979+ // If handle is invalid, use DID as display name
10280 if handle == "handle.invalid" || handle == "" {
10381 handle = resolvedDID
10482 }
8383+8484+ // PDS endpoint is required - we can't make XRPC calls without it
10585 if pdsEndpoint == "" {
106106- pdsEndpoint = "https://bsky.social"
8686+ return fmt.Errorf("no PDS endpoint found for DID: %s", resolvedDID)
10787 }
10888109109- // Fetch user's Bluesky profile (including avatar)
110110- // Use public Bluesky AppView API (doesn't require auth for public profiles)
111111- avatar := ""
112112- publicClient := atproto.NewClient("https://public.api.bsky.app", "", "")
113113- profile, err := publicClient.GetActorProfile(ctx, resolvedDID)
8989+ // Fetch user's Bluesky profile record from their PDS (including avatar)
9090+ avatarURL := ""
9191+ client := atproto.NewClient(pdsEndpoint, "", "")
9292+ profileRecord, err := client.GetProfileRecord(ctx, resolvedDID)
11493 if err != nil {
115115- slog.Warn("Failed to fetch profile", "component", "processor", "did", resolvedDID, "error", err)
9494+ slog.Warn("Failed to fetch profile record", "component", "processor", "did", resolvedDID, "error", err)
11695 // Continue without avatar
117117- } else {
118118- avatar = profile.Avatar
9696+ } else if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
9797+ avatarURL = atproto.BlobCDNURL(resolvedDID, profileRecord.Avatar.Ref.Link)
11998 }
12099121100 // Create user record
···123102 DID: resolvedDID,
124103 Handle: handle,
125104 PDSEndpoint: pdsEndpoint,
126126- Avatar: avatar,
105105+ Avatar: avatarURL,
127106 LastSeen: time.Now(),
128107 }
129108···132111 p.userCache.cache[did] = user
133112 }
134113135135- // Upsert to database
136136- return db.UpsertUser(p.db, user)
114114+ // Upsert to database - preserve existing avatar if fetch failed
115115+ return db.UpsertUserIgnoreAvatar(p.db, user)
137116}
138117139118// ProcessManifest processes a manifest record and stores it in the database