/**
* Context Fetcher
*
* Centralizes logic for fetching project state, collections, and content.
* Used by get_started and other tools that need project context.
*/
import { apiRequest, isApiError, needsAuthentication } from './api-client';
// ============ Types ============
export interface ProjectSummary {
id: string;
name: string;
subdomain: string;
customDomain: string | null;
status: 'pending' | 'building' | 'published' | 'failed';
hasCollections: boolean;
collectionCount: number;
hasContent: boolean;
totalItems: number;
hasPendingChanges: boolean;
lastDeployedAt: string | null;
githubConnected: boolean;
githubRepo: string | null;
}
export interface CollectionField {
slug: string;
name: string;
type: string;
isRequired: boolean;
referenceCollection?: string;
}
export interface CollectionSummary {
slug: string;
name: string;
nameSingular: string;
fieldCount: number;
itemCount: number;
fields: CollectionField[];
}
export interface ProjectDetail {
id: string;
name: string;
subdomain: string;
customDomain: string | null;
status: 'pending' | 'building' | 'published' | 'failed';
collections: CollectionSummary[];
hasPendingChanges: boolean;
lastDeployedAt: string | null;
githubConnected: boolean;
}
export interface ProjectContext {
isAuthenticated: boolean;
projects: ProjectSummary[];
selectedProject?: ProjectDetail;
}
// ============ API Response Types ============
interface TenantWithRole {
id: string;
name: string;
subdomain: string;
customDomain?: string | null;
role: string;
site?: {
id: string;
status: string;
hasPendingChanges?: boolean;
updatedAt?: string;
} | null;
}
interface Collection {
id: string;
slug: string;
name: string;
nameSingular: string;
fields: Array<{
id: string;
slug: string;
name: string;
type: string;
isRequired?: boolean;
referenceCollection?: string;
}>;
_count?: {
items: number;
};
}
interface GitHubConnection {
connected: boolean;
repo: string | null;
branch: string | null;
}
// ============ Helper Functions ============
/**
* Fetch all projects for the authenticated user
*/
async function fetchProjects(): Promise<TenantWithRole[]> {
const response = await apiRequest<TenantWithRole[]>('/api/tenants');
if (isApiError(response)) {
return [];
}
return response.data;
}
/**
* Fetch collections for a specific project
*/
async function fetchCollections(tenantId: string): Promise<Collection[]> {
const response = await apiRequest<Collection[]>('/api/collections', { tenantId });
if (isApiError(response)) {
return [];
}
return response.data;
}
/**
* Fetch GitHub connection status for a project
*/
async function fetchGitHubStatus(tenantId: string): Promise<GitHubConnection | null> {
const response = await apiRequest<GitHubConnection>('/api/github/site-connection', { tenantId });
if (isApiError(response)) {
return null;
}
return response.data;
}
// Note: fetchLatestDeploy could be used for detailed deploy state in the future
// async function fetchLatestDeploy(tenantId: string): Promise<DeployLog | null> {
// const response = await apiRequest<DeployLog[]>('/api/deploy/history?limit=1', { tenantId });
// if (isApiError(response) || !response.data || response.data.length === 0) {
// return null;
// }
// return response.data[0];
// }
/**
* Fetch item counts for collections
*/
async function fetchCollectionItemCounts(tenantId: string, collectionSlugs: string[]): Promise<Map<string, number>> {
const counts = new Map<string, number>();
// Fetch counts in parallel (limit concurrency)
const promises = collectionSlugs.slice(0, 10).map(async (slug) => {
const response = await apiRequest<{ total: number }>(`/api/collections/${slug}/items?limit=0`, { tenantId });
if (!isApiError(response) && response.data) {
counts.set(slug, response.data.total || 0);
}
});
await Promise.all(promises);
return counts;
}
// ============ Main Functions ============
/**
* Fetch complete project context
*
* @param projectId - Optional specific project to get detailed info for
*/
export async function fetchProjectContext(projectId?: string): Promise<ProjectContext> {
// Check authentication
const isAuthenticated = !(await needsAuthentication());
if (!isAuthenticated) {
return {
isAuthenticated: false,
projects: [],
};
}
// Fetch all projects
const tenants = await fetchProjects();
// Build project summaries
const projects: ProjectSummary[] = await Promise.all(
tenants.map(async (tenant) => {
// Fetch collections for this project (for count)
const collections = await fetchCollections(tenant.id);
const collectionCount = collections.length;
// Calculate total items
let totalItems = 0;
for (const col of collections) {
if (col._count?.items) {
totalItems += col._count.items;
}
}
// Fetch GitHub status
const github = await fetchGitHubStatus(tenant.id);
return {
id: tenant.id,
name: tenant.name,
subdomain: tenant.subdomain,
customDomain: tenant.customDomain || null,
status: (tenant.site?.status || 'pending') as ProjectSummary['status'],
hasCollections: collectionCount > 0,
collectionCount,
hasContent: totalItems > 0,
totalItems,
hasPendingChanges: tenant.site?.hasPendingChanges || false,
lastDeployedAt: tenant.site?.updatedAt || null,
githubConnected: github?.connected || false,
githubRepo: github?.repo || null,
};
})
);
// If a specific project is requested, get detailed info
let selectedProject: ProjectDetail | undefined;
if (projectId) {
// Find the project
const tenant = tenants.find(
t => t.id === projectId ||
t.name.toLowerCase() === projectId.toLowerCase() ||
t.subdomain.toLowerCase() === projectId.toLowerCase()
);
if (tenant) {
// Fetch collections with full field details
const collections = await fetchCollections(tenant.id);
// Fetch item counts
const itemCounts = await fetchCollectionItemCounts(
tenant.id,
collections.map(c => c.slug)
);
// Fetch GitHub status
const github = await fetchGitHubStatus(tenant.id);
// Build collection summaries
const collectionSummaries: CollectionSummary[] = collections.map(col => ({
slug: col.slug,
name: col.name,
nameSingular: col.nameSingular,
fieldCount: col.fields.length,
itemCount: itemCounts.get(col.slug) || col._count?.items || 0,
fields: col.fields.map(f => ({
slug: f.slug,
name: f.name,
type: f.type,
isRequired: f.isRequired || false,
referenceCollection: f.referenceCollection,
})),
}));
selectedProject = {
id: tenant.id,
name: tenant.name,
subdomain: tenant.subdomain,
customDomain: tenant.customDomain || null,
status: (tenant.site?.status || 'pending') as ProjectDetail['status'],
collections: collectionSummaries,
hasPendingChanges: tenant.site?.hasPendingChanges || false,
lastDeployedAt: tenant.site?.updatedAt || null,
githubConnected: github?.connected || false,
};
}
}
return {
isAuthenticated: true,
projects,
selectedProject,
};
}
/**
* Resolve a project identifier to a tenant ID
*/
export async function resolveProjectId(projectIdentifier: string): Promise<{ tenantId: string; name: string; subdomain: string } | null> {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const tenants = await fetchProjects();
// Try exact UUID match
if (uuidPattern.test(projectIdentifier)) {
const tenant = tenants.find(t => t.id === projectIdentifier);
if (tenant) {
return { tenantId: tenant.id, name: tenant.name, subdomain: tenant.subdomain };
}
}
// Try exact name match
const exactMatch = tenants.find(
t => t.name.toLowerCase() === projectIdentifier.toLowerCase()
);
if (exactMatch) {
return { tenantId: exactMatch.id, name: exactMatch.name, subdomain: exactMatch.subdomain };
}
// Try subdomain match
const subdomainMatch = tenants.find(
t => t.subdomain.toLowerCase() === projectIdentifier.toLowerCase()
);
if (subdomainMatch) {
return { tenantId: subdomainMatch.id, name: subdomainMatch.name, subdomain: subdomainMatch.subdomain };
}
// Try partial match
const partialMatch = tenants.find(
t => t.name.toLowerCase().includes(projectIdentifier.toLowerCase())
);
if (partialMatch) {
return { tenantId: partialMatch.id, name: partialMatch.name, subdomain: partialMatch.subdomain };
}
return null;
}