diff --git a/features/auth/link.go b/features/auth/link.go index 8c12ed4..59d8903 100644 --- a/features/auth/link.go +++ b/features/auth/link.go @@ -157,11 +157,43 @@ func (h *Handlers) LinkLocalToATProto(w http.ResponseWriter, r *http.Request) { // Award badge to PDS if _, err := badge.Award(ctx, sess, h.DB, badge.AwardType(trigger), ""); err != nil { slog.Warn("link: migrate badge", "trigger", trigger, "err", err) + // Keep old local row — DrainPending will retry later + } else { + // Remove old local row to prevent PRIMARY KEY conflict in step 5 + if _, e := tx.ExecContext(ctx, ` + DELETE FROM earned_badges + WHERE did = ? AND badge_id = ( + SELECT id FROM badge_definitions WHERE trigger = ? + ) + `, localID, trigger); e != nil { + slog.Warn("link: cleanup old badge row", "err", e) + } } } badgeRows.Close() } + // 4b. Migrate pending_local_connections to pending_connections + localPendingRes, err := tx.ExecContext(ctx, ` + INSERT INTO pending_connections (target_did, initiator_did, event_uri, created_at) + SELECT target_did, ?, event_uri, created_at + FROM pending_local_connections + WHERE initiator_local_id = ? + ON CONFLICT(target_did, initiator_did, event_uri) DO NOTHING + `, did.String(), localID) + if err != nil { + slog.Warn("link: migrate pending_local_connections", "err", err) + } else { + n, _ := localPendingRes.RowsAffected() + if n > 0 { + slog.Info("link: migrated pending_local_connections", "count", n) + } + } + // Delete migrated rows + _, _ = tx.ExecContext(ctx, ` + DELETE FROM pending_local_connections WHERE initiator_local_id = ? + `, localID) + // 5. Update local tables: change did from localID to new DID. // Note: connection_notes uses viewer_did, not did. tables := []struct { @@ -201,6 +233,12 @@ func (h *Handlers) LinkLocalToATProto(w http.ResponseWriter, r *http.Request) { slog.Warn("link: update local_connections", "err", err) } + // Also update connections where the graduating user was the target + _, _ = tx.ExecContext(ctx, ` + UPDATE local_connections SET target_did = ?, target_local_id = NULL + WHERE target_local_id = ? + `, did.String(), localID) + // 8. Record the link in account_links table _, err = tx.ExecContext(ctx, ` INSERT OR IGNORE INTO account_links (local_did, did) VALUES (?, ?) diff --git a/features/connect/handlers.go b/features/connect/handlers.go index 39cfe63..0aa995e 100644 --- a/features/connect/handlers.go +++ b/features/connect/handlers.go @@ -101,6 +101,18 @@ func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { return } + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusFound) + return + } + + // Handle local viewer + if identity.IsLocal { + h.connectLocalToATProto(w, r, identity.DID, target) + return + } + viewerDID, viewerSess, ok := h.Auth.RequireSession(w, r) if !ok { return // RequireSession redirected to /signin?next=... @@ -177,6 +189,60 @@ func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/connections/"+target.String(), http.StatusSeeOther) } +// connectLocalToATProto handles the case where a local user scans an ATProto +// user's QR code. Writes a local_connections row, enqueues a reciprocal for +// when the local user graduates, auto-checks-in if the target is at an event, +// and awards badges. +func (h *Handlers) connectLocalToATProto(w http.ResponseWriter, r *http.Request, localID string, target syntax.DID) { + // Self-connect: local user scanning their own connected ATProto account + if strings.HasPrefix(target.String(), "did:plc:") { + var linkedDID string + err := h.DB.QueryRowContext(r.Context(), + `SELECT did FROM account_links WHERE local_did = ?`, localID).Scan(&linkedDID) + if err == nil && linkedDID == target.String() { + http.Redirect(w, r, "/profile", http.StatusSeeOther) + return + } + } + + // Auto-detect if target is at an ongoing event + var eventURI string + if evURI, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok { + if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { + eventURI = evURI + } + } + + // Write local connection + err := h.writeLocalConnection(r.Context(), "", localID, target.String(), "", eventURI) + if err != nil { + slog.Warn("connect local→atproto: write connection", "err", err) + } + + // Enqueue reciprocal for later (graduation will migrate this) + if err := h.Queue.EnqueueLocal(r.Context(), target, localID, eventURI); err != nil { + slog.Warn("connect local→atproto: enqueue reciprocal", "err", err) + } + + // Auto-checkin if target at event + if eventURI != "" { + if err := checkin.PutLocal(r.Context(), h.DB, localID, eventURI, time.Time{}); err != nil { + slog.Warn("connect local→atproto: auto-checkin", "err", err) + } + _ = event.IncrementCheckins(r.Context(), h.DB, eventURI) + _ = event.IncrementConnectors(r.Context(), h.DB, eventURI) + } + + // Badges + _ = badge.AwardLocal(r.Context(), h.DB, localID, badge.AwardFirstConnect) + if eventURI != "" { + _ = badge.AwardLocal(r.Context(), h.DB, localID, badge.AwardEventAttendee) + } + badge.AwardConnectionMilestonesLocal(r.Context(), h.DB, localID) + + http.Redirect(w, r, "/connections/"+target.String(), http.StatusSeeOther) +} + // ConnectLocal handles GET /c/l/{local_id} — QR code scan for local users. func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { targetLocalID := chi.URLParam(r, "local_id") @@ -214,6 +280,8 @@ func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { viewerDIDStr := "" if ok { viewerDIDStr = viewerDID.String() + } else { + viewerDIDStr = viewerLocalID } err = h.writeLocalConnection(r.Context(), viewerDIDStr, viewerLocalID, "", targetLocalID, "") @@ -221,6 +289,41 @@ func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { slog.Warn("connect local: write connection", "err", err) } + // Auto-checkin if target is at an ongoing event + var eventURI string + if evURI, ok, err := checkin.CurrentForLocal(r.Context(), h.DB, targetLocalID); err == nil && ok { + if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { + eventURI = evURI + } + } + if eventURI != "" { + if err := checkin.PutLocal(r.Context(), h.DB, viewerDIDStr, eventURI, time.Time{}); err == nil { + _ = event.IncrementCheckins(r.Context(), h.DB, eventURI) + _ = event.IncrementConnectors(r.Context(), h.DB, eventURI) + } + _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardEventAttendee) + } + + // Besties badge: check if already connected at a different event + if eventURI != "" { + var eventCount int + _ = h.DB.QueryRowContext(r.Context(), ` + SELECT COUNT(DISTINCT event_uri) FROM local_connections + WHERE ((viewer_did = ? AND viewer_local_id IS NULL) OR (viewer_local_id = ? AND viewer_did IS NULL)) + AND ((target_did = ? AND target_local_id IS NULL) OR (target_local_id = ? AND target_did IS NULL)) + AND event_uri != '' + `, viewerDIDStr, viewerDIDStr, targetLocalID, targetLocalID).Scan(&eventCount) + if eventCount >= 2 { + _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardBesties) + } + } + + // First-connect badge + _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardFirstConnect) + + // Connection milestone badges + badge.AwardConnectionMilestonesLocal(r.Context(), h.DB, viewerDIDStr) + // Redirect to show connection if viewerLocalID != "" { http.Redirect(w, r, "/connections/l/"+targetLocalID, http.StatusSeeOther) @@ -268,8 +371,9 @@ func (h *Handlers) renderConnectLocalPage(w http.ResponseWriter, r *http.Request // // { "written": 2, "skipped": 0, "errors": [] } func (h *Handlers) ConnectFlushLocal(w http.ResponseWriter, r *http.Request) { - viewerDID, viewerSess, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Error(w, "not authenticated", http.StatusUnauthorized) return } @@ -283,55 +387,104 @@ func (h *Handlers) ConnectFlushLocal(w http.ResponseWriter, r *http.Request) { written, skipped := 0, 0 errs := make([]string, 0) - for _, raw := range body.Targets { - raw = strings.TrimSpace(raw) - if raw == "" { - continue + + if identity.IsLocal { + for _, raw := range body.Targets { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if strings.HasPrefix(raw, "local_") { + // local→local: write local_connections + err := connection.WriteLocal(r.Context(), h.DB, "", identity.DID, "", raw, "") + if err != nil { + skipped++ + errs = append(errs, "write failed for "+raw) + continue + } + _ = badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardFirstConnect) + written++ + } else { + // local→ATProto: write local_connections + queue reciprocal + target, err := syntax.ParseDID(raw) + if err != nil { + skipped++ + continue + } + err = connection.WriteLocal(r.Context(), h.DB, "", identity.DID, target.String(), "", "") + if err != nil { + skipped++ + continue + } + _ = h.Queue.EnqueueLocal(r.Context(), target, identity.DID, "") + _ = badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardFirstConnect) + written++ + } + } + } else { + viewerDID, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + return } - // Handle local_ IDs — write to local_connections table. - if strings.HasPrefix(raw, "local_") { - viewerDIDStr := viewerDID.String() - err := connection.WriteLocal(r.Context(), h.DB, viewerDIDStr, "", "", raw, "") + for _, raw := range body.Targets { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + // Handle local_ IDs — write to local_connections table. + if strings.HasPrefix(raw, "local_") { + // Self-connect guard: check if this local ID was previously linked to the viewer + var linkedDID string + _ = h.DB.QueryRowContext(r.Context(), + `SELECT did FROM account_links WHERE local_did = ?`, raw).Scan(&linkedDID) + if linkedDID == viewerDID.String() { + continue // silently skip self + } + + viewerDIDStr := viewerDID.String() + err := connection.WriteLocal(r.Context(), h.DB, viewerDIDStr, "", "", raw, "") + if err != nil { + slog.Warn("flush-local: write local connection", "target", raw, "err", err) + skipped++ + errs = append(errs, "write failed for "+raw) + continue + } + _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardFirstConnect) + written++ + continue + } + + target, err := syntax.ParseDID(raw) if err != nil { - slog.Warn("flush-local: write local connection", "target", raw, "err", err) skipped++ - errs = append(errs, "write failed for "+raw) + errs = append(errs, "invalid DID: "+truncate(raw, 64)) + continue + } + if target == viewerDID { + continue + } + viewerPDS := h.lookupPDSForDID(r, viewerDID) + if connection.HasConnection(r.Context(), viewerPDS, viewerDID, target, "") { + skipped++ + continue + } + if _, _, err := connection.Put(r.Context(), viewerSess, h.DB, connection.Record{With: target}); err != nil { + slog.Warn("flush-local: viewer record", "target", target.String(), "err", err) + skipped++ + errs = append(errs, "write failed for "+target.String()) continue } + if err := h.Queue.Enqueue(r.Context(), target, viewerDID, ""); err != nil { + slog.Warn("flush-local: enqueue", "target", target.String(), "err", err) + } + // Award first-connect badge (idempotent). + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, ""); err != nil { + slog.Info("flush-local: first-connect badge", "err", err) + } written++ - continue - } - - target, err := syntax.ParseDID(raw) - if err != nil { - skipped++ - errs = append(errs, "invalid DID: "+truncate(raw, 64)) - continue - } - if target == viewerDID { - // silently skip self - continue - } - viewerPDS := h.lookupPDSForDID(r, viewerDID) - if connection.HasConnection(r.Context(), viewerPDS, viewerDID, target, "") { - skipped++ - continue - } - if _, _, err := connection.Put(r.Context(), viewerSess, h.DB, connection.Record{With: target}); err != nil { - slog.Warn("flush-local: viewer record", "target", target.String(), "err", err) - skipped++ - errs = append(errs, "write failed for "+target.String()) - continue - } - if err := h.Queue.Enqueue(r.Context(), target, viewerDID, ""); err != nil { - slog.Warn("flush-local: enqueue", "target", target.String(), "err", err) - } - // Award first-connect badge (idempotent). - if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, ""); err != nil { - slog.Info("flush-local: first-connect badge", "err", err) } - written++ } w.Header().Set("Content-Type", "application/json") diff --git a/features/event/handlers.go b/features/event/handlers.go index ebd83e1..963940c 100644 --- a/features/event/handlers.go +++ b/features/event/handlers.go @@ -117,6 +117,10 @@ func (h *Handlers) EventScanCheckin(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to check in", http.StatusInternalServerError) return } + // Award event-attendee badge (idempotent). + if err := badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardEventAttendee); err != nil { + slog.Warn("event scan: event-attendee badge (local)", "err", err) + } } else { _, viewerSess, ok := h.Auth.RequireSession(w, r) if !ok { @@ -199,6 +203,12 @@ func (h *Handlers) EventFlushLocal(w http.ResponseWriter, r *http.Request) { } written = wLocal skipped += sLocal + + // Award badges + increment stats for newly written checkins + for _, evURI := range eventURIs { + _ = badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardEventAttendee) + _ = event.IncrementCheckins(r.Context(), h.DB, evURI) + } } else { errs := make([]string, 0) for _, evURI := range eventURIs { diff --git a/internal/badge/award.go b/internal/badge/award.go index 2c2352e..60dc957 100644 --- a/internal/badge/award.go +++ b/internal/badge/award.go @@ -31,6 +31,67 @@ const ( // connectionMilestones are the thresholds that trigger a badge award. var connectionMilestones = []int{10, 50, 100, 200, 300, 400, 500} +// AwardLocal writes a badge award to the local SQLite cache without +// touching any PDS. Used for local (non-ATProto) users. +// record_uri is set to '' since there's no PDS record. +// Idempotent: no-op if the badge is already earned. +func AwardLocal(ctx context.Context, db *sql.DB, did string, badgeType AwardType) error { + def, err := GetDefinitionByTrigger(ctx, db, string(badgeType)) + if err != nil { + return fmt.Errorf("badge.AwardLocal: %w", err) + } + if HasEarned(ctx, db, did, def.ID) { + return nil + } + return RecordEarned(ctx, db, did, def.ID, "") +} + +// AwardConnectionMilestonesLocal is the local-user variant of +// AwardConnectionMilestones. Uses AwardLocal instead of Award. +func AwardConnectionMilestonesLocal(ctx context.Context, db *sql.DB, did string) { + if db == nil { + return + } + count := CountConnectionsForDID(ctx, db, did) + if count <= 0 { + return + } + for _, threshold := range connectionMilestones { + if count >= threshold { + trigger := fmt.Sprintf("connections-%d", threshold) + def, err := GetDefinitionByTrigger(ctx, db, trigger) + if err != nil { + continue + } + if HasEarned(ctx, db, did, def.ID) { + continue + } + _ = AwardLocal(ctx, db, did, AwardType(trigger)) + } + } + // Auto-create badges for multiples of 100 beyond 500. + if count >= 600 { + for t := 600; t <= count; t += 100 { + trigger := fmt.Sprintf("connections-%d", t) + _, err := GetDefinitionByTrigger(ctx, db, trigger) + if err != nil { + _, _ = CreateDefinition(ctx, db, Definition{ + Slug: trigger, Name: fmt.Sprintf("%d Connections", t), + Description: fmt.Sprintf("Made %d connections", t), + Shape: "circle", PrimaryColor: "#cba6f7", + AccentColor: "#1e1e2e", RibbonColor: "#fab387", + Label: fmt.Sprintf("%d", t), Trigger: trigger, + }) + } + def, err := GetDefinitionByTrigger(ctx, db, trigger) + if err != nil || HasEarned(ctx, db, did, def.ID) { + continue + } + _ = AwardLocal(ctx, db, did, AwardType(trigger)) + } + } +} + // AwardConnectionMilestones checks the user's total connection count and // awards any milestone badges they've reached but haven't earned yet. // Call after every new connection. Best-effort — errors are logged not returned. diff --git a/internal/checkin/checkin.go b/internal/checkin/checkin.go index 4b7d558..277a016 100644 --- a/internal/checkin/checkin.go +++ b/internal/checkin/checkin.go @@ -211,3 +211,24 @@ func Current(ctx context.Context, db *sql.DB, did syntax.DID) (eventURI string, } return eventURI, true, nil } + +// CurrentForLocal is like Current but accepts a string DID (supports local IDs). +func CurrentForLocal(ctx context.Context, db *sql.DB, did string) (eventURI string, ok bool, err error) { + row := db.QueryRowContext(ctx, ` + SELECT c.event_uri + FROM checkins c + JOIN events e ON e.uri = c.event_uri + WHERE c.did = ? + AND CURRENT_TIMESTAMP BETWEEN e.start_time AND e.end_time + ORDER BY c.checked_in_at DESC + LIMIT 1 + `, did) + err = row.Scan(&eventURI) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return eventURI, true, nil +} diff --git a/internal/connection/queue.go b/internal/connection/queue.go index 3b5a97f..1e38caf 100644 --- a/internal/connection/queue.go +++ b/internal/connection/queue.go @@ -51,6 +51,21 @@ func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID, event return nil } +// EnqueueLocal records that an ATProto target owes a reciprocal connection +// to a local user. After the local user graduates, these rows are migrated +// to the main pending_connections table. +func (q *Queue) EnqueueLocal(ctx context.Context, target syntax.DID, initiatorLocalID, eventURI string) error { + if target == "" || initiatorLocalID == "" { + return errors.New("connection queue: empty target DID or initiator local ID") + } + _, err := q.db.ExecContext(ctx, ` + INSERT INTO pending_local_connections (target_did, initiator_local_id, event_uri) + VALUES (?, ?, ?) + ON CONFLICT(target_did, initiator_local_id, event_uri) DO NOTHING + `, target.String(), initiatorLocalID, eventURI) + return err +} + // List returns up to `limit` pending items for the given target. If limit is // <= 0, all pending items are returned. func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]PendingItem, error) { diff --git a/internal/db/migrations/019_pending_local_connections.sql b/internal/db/migrations/019_pending_local_connections.sql new file mode 100644 index 0000000..40f32a7 --- /dev/null +++ b/internal/db/migrations/019_pending_local_connections.sql @@ -0,0 +1,17 @@ +-- Queue for reciprocal connections initiated by local (non-ATProto) users. +-- When a local user graduates, rows here are migrated to pending_connections +-- with initiator_local_id replaced by the new ATProto DID. +CREATE TABLE IF NOT EXISTS pending_local_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_did TEXT NOT NULL, -- ATProto user who owes a reciprocal connection + initiator_local_id TEXT NOT NULL, -- local_xxx who initiated + event_uri TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (target_did) REFERENCES users(did) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS pending_local_target_idx + ON pending_local_connections (target_did); + +CREATE UNIQUE INDEX IF NOT EXISTS pending_local_unique_idx + ON pending_local_connections (target_did, initiator_local_id, event_uri);