feat: add ListOfficialProfiles endpoint + profiles grid in admin UI

This commit is contained in:
Patrick Britton 2026-02-08 11:39:27 -06:00
parent 2dae622dea
commit 3d371e965e
5 changed files with 122 additions and 7 deletions

View file

@ -62,26 +62,47 @@ interface Config {
avatar_url: string; avatar_url: string;
} }
interface OfficialProfile {
profile_id: string;
user_id: string;
handle: string;
display_name: string;
avatar_url: string;
bio: string;
is_verified: boolean;
has_config: boolean;
config_id?: string;
}
export default function OfficialAccountsPage() { export default function OfficialAccountsPage() {
const [configs, setConfigs] = useState<Config[]>([]); const [configs, setConfigs] = useState<Config[]>([]);
const [profiles, setProfiles] = useState<OfficialProfile[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [setupProfile, setSetupProfile] = useState<OfficialProfile | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({}); const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message: string }>>({}); const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message: string }>>({});
const fetchConfigs = async () => { const fetchAll = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await api.listOfficialAccounts(); const [configData, profileData] = await Promise.all([
setConfigs(data.configs || []); api.listOfficialAccounts(),
api.listOfficialProfiles(),
]);
setConfigs(configData.configs || []);
setProfiles(profileData.profiles || []);
} catch { } catch {
setConfigs([]); setConfigs([]);
setProfiles([]);
} }
setLoading(false); setLoading(false);
}; };
useEffect(() => { fetchConfigs(); }, []); const fetchConfigs = fetchAll;
useEffect(() => { fetchAll(); }, []);
const setAction = (id: string, loading: boolean, result?: { ok: boolean; message: string }) => { const setAction = (id: string, loading: boolean, result?: { ok: boolean; message: string }) => {
setActionLoading((p) => ({ ...p, [id]: loading })); setActionLoading((p) => ({ ...p, [id]: loading }));
@ -152,7 +173,40 @@ export default function OfficialAccountsPage() {
</div> </div>
</div> </div>
{showForm && <CreateAccountForm onDone={() => { setShowForm(false); fetchConfigs(); }} />} {showForm && <CreateAccountForm onDone={() => { setShowForm(false); fetchAll(); }} initialProfile={setupProfile} />}
{/* Official Profiles Overview */}
{!loading && profiles.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-semibold text-gray-600 uppercase tracking-wide mb-3">Official Profiles ({profiles.length})</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{profiles.map((p) => (
<div key={p.profile_id} className="bg-white rounded-lg border border-warm-300 p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-brand-100 rounded-full flex items-center justify-center flex-shrink-0 text-brand-600 font-bold text-sm">
{p.avatar_url ? <img src={p.avatar_url} className="w-9 h-9 rounded-full object-cover" /> : p.handle[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-gray-900 text-sm truncate">@{p.handle}</span>
{p.is_verified && <CheckCircle className="w-3.5 h-3.5 text-brand-500 flex-shrink-0" />}
</div>
<p className="text-xs text-gray-500 truncate">{p.display_name}</p>
</div>
{p.has_config ? (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-green-100 text-green-700 flex-shrink-0">Configured</span>
) : (
<button
onClick={() => { setSetupProfile(p); setShowForm(true); }}
className="text-xs px-2 py-1 bg-brand-50 text-brand-600 rounded hover:bg-brand-100 transition-colors flex-shrink-0"
>
Setup AI
</button>
)}
</div>
))}
</div>
</div>
)}
{loading ? ( {loading ? (
<div className="text-center py-12 text-gray-500">Loading...</div> <div className="text-center py-12 text-gray-500">Loading...</div>
@ -276,8 +330,8 @@ export default function OfficialAccountsPage() {
} }
// ─── Create Account Form ────────────────────────────── // ─── Create Account Form ──────────────────────────────
function CreateAccountForm({ onDone }: { onDone: () => void }) { function CreateAccountForm({ onDone, initialProfile }: { onDone: () => void; initialProfile?: OfficialProfile | null }) {
const [handle, setHandle] = useState(''); const [handle, setHandle] = useState(initialProfile?.handle || '');
const [accountType, setAccountType] = useState('general'); const [accountType, setAccountType] = useState('general');
const [modelId, setModelId] = useState('google/gemini-2.0-flash-001'); const [modelId, setModelId] = useState('google/gemini-2.0-flash-001');
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_GENERAL_PROMPT); const [systemPrompt, setSystemPrompt] = useState(DEFAULT_GENERAL_PROMPT);

View file

@ -395,6 +395,10 @@ class ApiClient {
} }
// Official Accounts // Official Accounts
async listOfficialProfiles() {
return this.request<any>('/api/v1/admin/official-accounts/profiles');
}
async listOfficialAccounts() { async listOfficialAccounts() {
return this.request<any>('/api/v1/admin/official-accounts'); return this.request<any>('/api/v1/admin/official-accounts');
} }

View file

@ -481,6 +481,7 @@ func main() {
// Official Accounts Management // Official Accounts Management
admin.GET("/official-accounts", adminHandler.ListOfficialAccounts) admin.GET("/official-accounts", adminHandler.ListOfficialAccounts)
admin.GET("/official-accounts/profiles", adminHandler.ListOfficialProfiles)
admin.GET("/official-accounts/:id", adminHandler.GetOfficialAccount) admin.GET("/official-accounts/:id", adminHandler.GetOfficialAccount)
admin.POST("/official-accounts", adminHandler.UpsertOfficialAccount) admin.POST("/official-accounts", adminHandler.UpsertOfficialAccount)
admin.DELETE("/official-accounts/:id", adminHandler.DeleteOfficialAccount) admin.DELETE("/official-accounts/:id", adminHandler.DeleteOfficialAccount)

View file

@ -2833,6 +2833,18 @@ func (h *AdminHandler) ListOfficialAccounts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"configs": configs}) c.JSON(http.StatusOK, gin.H{"configs": configs})
} }
func (h *AdminHandler) ListOfficialProfiles(c *gin.Context) {
profiles, err := h.officialAccountsService.ListOfficialProfiles(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if profiles == nil {
profiles = []services.OfficialProfile{}
}
c.JSON(http.StatusOK, gin.H{"profiles": profiles})
}
func (h *AdminHandler) GetOfficialAccount(c *gin.Context) { func (h *AdminHandler) GetOfficialAccount(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
cfg, err := h.officialAccountsService.GetConfig(c.Request.Context(), id) cfg, err := h.officialAccountsService.GetConfig(c.Request.Context(), id)

View file

@ -608,6 +608,50 @@ func stripHTMLTags(s string) string {
return strings.TrimSpace(result.String()) return strings.TrimSpace(result.String())
} }
// OfficialProfile represents a profile with is_official = true
type OfficialProfile struct {
ProfileID string `json:"profile_id"`
UserID string `json:"user_id"`
Handle string `json:"handle"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Bio string `json:"bio"`
IsVerified bool `json:"is_verified"`
HasConfig bool `json:"has_config"`
ConfigID *string `json:"config_id,omitempty"`
}
// ListOfficialProfiles returns all profiles where is_official = true,
// along with whether they have an official_account_configs entry
func (s *OfficialAccountsService) ListOfficialProfiles(ctx context.Context) ([]OfficialProfile, error) {
rows, err := s.pool.Query(ctx, `
SELECT p.id, p.user_id, p.handle, p.display_name, COALESCE(p.avatar_url, ''),
COALESCE(p.bio, ''), COALESCE(p.is_verified, false),
c.id AS config_id
FROM public.profiles p
LEFT JOIN official_account_configs c ON c.profile_id = p.id
WHERE p.is_official = true
ORDER BY p.handle
`)
if err != nil {
return nil, err
}
defer rows.Close()
var profiles []OfficialProfile
for rows.Next() {
var p OfficialProfile
var configID *string
if err := rows.Scan(&p.ProfileID, &p.UserID, &p.Handle, &p.DisplayName, &p.AvatarURL, &p.Bio, &p.IsVerified, &configID); err != nil {
continue
}
p.ConfigID = configID
p.HasConfig = configID != nil
profiles = append(profiles, p)
}
return profiles, nil
}
// LookupProfileID finds a profile ID by handle // LookupProfileID finds a profile ID by handle
func (s *OfficialAccountsService) LookupProfileID(ctx context.Context, handle string) (string, error) { func (s *OfficialAccountsService) LookupProfileID(ctx context.Context, handle string) (string, error) {
var id string var id string