import { ApiError, FireCMSCloudUserWithRoles, SubscriptionType } from "../types"; import { handleApiResponse } from "./common"; import { EntityCollection } from "@firecms/core"; export type ProjectsApi = ReturnType; export type RootCollectionInfo = { path: string; databaseId?: string; }; const rootCollectionsCache: { [key: string]: RootCollectionInfo[] } = {}; export function buildProjectsApi(host: string, getBackendAuthToken: () => Promise) { async function createNewFireCMSProject( projectId: string, googleAccessToken: string | undefined, serviceAccount: object | undefined, creationType: "new" | "existing" | "existing_sa", campaignParams?: Record ) { const firebaseAccessToken = await getBackendAuthToken(); return fetch(host + "/projects", { method: "POST", headers: buildHeaders({ firebaseAccessToken, googleAccessToken, serviceAccount }), body: JSON.stringify({ projectId, creationType, serviceAccount: serviceAccount ?? null, campaignParams: campaignParams ?? null }) }) .then(async (res) => { return handleApiResponse(res, projectId).then((_) => true); }); } async function createFirebaseWebapp(projectId: string) { const firebaseAccessToken = await getBackendAuthToken(); return fetch(host + `/projects/${projectId}/firebase_webapp`, { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify({ projectId }) }) .then(async (res) => { return handleApiResponse(res, projectId).then((_) => true); }); } async function addSecurityRules(projectId: string, googleAccessToken?: string, serviceAccount?: object) { const firebaseAccessToken = await getBackendAuthToken(); return fetch(host + `/projects/${projectId}/firestore_security_rules`, { method: "PATCH", headers: buildHeaders({ firebaseAccessToken, googleAccessToken, serviceAccount }), }) .then(async (res) => { return handleApiResponse(res, projectId).then((_) => true); }); } async function createNewUser(projectId: string, user: FireCMSCloudUserWithRoles): Promise { const firebaseAccessToken = await getBackendAuthToken(); const persistedUserData = { ...user, roles: user.roles.map(r => r.id), updated_on: new Date() } return fetch(host + "/projects/" + projectId + "/users", { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify(persistedUserData) }) .then((res) => { return handleApiResponse(res, projectId); }); } async function updateUser(projectId: string, uid: string, user: FireCMSCloudUserWithRoles): Promise { const firebaseAccessToken = await getBackendAuthToken(); const persistedUserData = { ...user, uid: uid, firebase_uid: user.uid, // Preserve client app uid as firebase_uid roles: user.roles.map(r => r.id), updated_on: new Date() } const apiUrl = host + "/projects/" + projectId + "/users/" + uid; return fetch(apiUrl, { method: "PATCH", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify(persistedUserData) }) .then((res) => { return handleApiResponse(res, projectId); }); } async function deleteUser(projectId: string, uid: string): Promise { const firebaseAccessToken = await getBackendAuthToken(); return fetch(host + "/projects/" + projectId + "/users/" + uid, { method: "DELETE", headers: buildHeaders({ firebaseAccessToken }) }) .then((res) => { return handleApiResponse(res, projectId); }); } async function getRootCollections(projectId: string, googleAccessToken?: string, serviceAccount?: object, retries = 10): Promise { if (rootCollectionsCache[projectId]) { return rootCollectionsCache[projectId]; } const firebaseAccessToken = await getBackendAuthToken(); async function retry() { // wait 5 seconds await new Promise(resolve => setTimeout(resolve, 5000)); console.debug("Retrying getRootCollections", retries); return getRootCollections(projectId, googleAccessToken, serviceAccount, retries - 1); } return fetch(host + "/projects/" + projectId + "/firestore_root_collections", { method: "GET", headers: buildHeaders({ firebaseAccessToken, googleAccessToken, serviceAccount }), }) .then(async (res) => { // Don't retry on 429 (quota exhausted) or 403 (forbidden) — these won't resolve with retries if (res.status === 429) { console.warn("Quota exhausted for getRootCollections, returning empty", { projectId }); return []; } if (res.status === 403) { console.warn("Permission denied for getRootCollections", { projectId }); return []; } if (res.status >= 300) { if (retries > 0) { return await retry(); } return []; } const data = await handleApiResponse<{ collections?: string[]; collectionsWithDB?: Array<{ path: string; databaseId?: string }>; databaseId?: string; } | string[]>(res, projectId); console.debug("getRootCollections response:", data); // Use new format if available, otherwise fall back to legacy format let result: RootCollectionInfo[]; if (Array.isArray(data)) { // Very old format: plain array of strings result = data.map(path => ({ path: path as string })); } else if (data && typeof data === "object") { if (data.collectionsWithDB && Array.isArray(data.collectionsWithDB)) { result = data.collectionsWithDB; } else if (data.collections && Array.isArray(data.collections)) { // Legacy format: just path strings result = data.collections.map(path => ({ path, databaseId: data.databaseId })); } else { console.warn("Unexpected getRootCollections response format:", data); result = []; } } else { console.warn("Unexpected getRootCollections response type:", typeof data, data); result = []; } rootCollectionsCache[projectId] = result; return result; }) .catch(async (error) => { if (retries > 0) { return await retry(); } else { throw error; } }); } async function createServiceAccount(googleAccessToken: string, projectId: string, reset: boolean): Promise { const firebaseAccessToken = await getBackendAuthToken(); const url = `${host}/projects/${projectId}/service_accounts?reset=${reset}`; return fetch(url, { method: "POST", headers: buildHeaders({ firebaseAccessToken, googleAccessToken }), }) .then(async (res) => { if (res.status === 409) // already exists throw Error("The service account already exists for this project.") const data = await res.json(); return data.user as FireCMSCloudUserWithRoles; }); } async function doDelegatedLogin(projectId: string): Promise { const firebaseAccessToken = await getBackendAuthToken(); return fetch(host + "/projects/" + projectId + "/delegated_login", { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify({ projectId }) }) .then(async (res) => { const data = await res.json(); if (!res.ok) { throw new ApiError(data.message, data.code, projectId, data.data); } return data.data as string; }); } async function getStripePortalLink(): Promise { const firebaseAccessToken = await getBackendAuthToken(); return fetch(`${host}/customer/stripe_portal_link?return_url=${window.location.href}`, { method: "GET", headers: buildHeaders({ firebaseAccessToken }), }) .then(async (res) => { const data = await res.json(); return data.url as string; }); } async function getStripeCancelLinkForSubscription(subscriptionId: string, projectId?: string): Promise { const firebaseAccessToken = await getBackendAuthToken(); let url = `${host}/customer/stripe_portal_link/cancel_subscription?return_url=${encodeURIComponent(window.location.href)}&subscription_id=${subscriptionId}`; if (projectId) url += `&project_id=${projectId}`; return fetch(url, { method: "GET", headers: buildHeaders({ firebaseAccessToken }), }) .then(async (res) => { const data = await res.json(); return data.url as string; }); } async function getStripeUpdateLinkForSubscription(subscriptionId: string, projectId?: string): Promise { const firebaseAccessToken = await getBackendAuthToken(); let url = `${host}/customer/stripe_portal_link/update_subscription?return_url=${encodeURIComponent(window.location.href)}&subscription_id=${subscriptionId}`; if (projectId) url += `&project_id=${projectId}`; return fetch(url, { method: "GET", headers: buildHeaders({ firebaseAccessToken }), }) .then(async (res) => { const data = await res.json(); return data.url as string; }); } async function getStripeUpdateLinkForPaymentMethod(subscriptionId: string, projectId?: string): Promise { const firebaseAccessToken = await getBackendAuthToken(); let url = `${host}/customer/stripe_portal_link/update_payment_method?return_url=${encodeURIComponent(window.location.href)}&subscription_id=${subscriptionId}`; if (projectId) url += `&project_id=${projectId}`; return fetch(url, { method: "GET", headers: buildHeaders({ firebaseAccessToken }), }) .then(async (res) => { const data = await res.json(); return data.url as string; }); } async function createStripeNewSubscriptionLink(props: { projectId?: string, licenseId?: string, quantity?: number, productPriceId: string, productPriceType: string, type: SubscriptionType }): Promise { const firebaseAccessToken = await getBackendAuthToken(); return fetch(`${host}/customer/create-checkout-session?return_url=${window.location.href}`, { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify(props) }) .then(async (res) => { const data = await res.json(); if (!res.ok) { throw new Error(data?.error ?? "Error creating checkout session"); } return data.url as string; }); } async function createCloudStripeNewSubscriptionLink(props: { projectId: string, currency: string }): Promise { const firebaseAccessToken = await getBackendAuthToken(); return fetch(`${host}/customer/cloud-checkout-session?return_url=${encodeURIComponent(window.location.href)}`, { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify(props) }) .then(async (res) => { const data = await res.json(); if (!res.ok) { throw new Error(data?.error ?? "Error creating checkout session"); } console.debug("createCloudStripeNewSubscriptionLink response", data); return data.url as string; }); } async function getRemoteConfigUrl(projectId: string, revisionId?: string) { return `${host}/projects/${projectId}/app_config/${revisionId}/${await getBackendAuthToken()}/remoteEntry.js`; } async function initialCollectionsSetup(projectId: string): Promise { const firebaseAccessToken = await getBackendAuthToken(); return fetch(`${host}/projects/${projectId}/initial_setup`, { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify({ projectId }) }) .then(async (res) => { const data = await handleApiResponse<{ collections: EntityCollection[] }>(res, projectId); return data.collections; }); } async function setupCollections( projectId: string, paths: { path: string; databaseId?: string }[] ): Promise { const firebaseAccessToken = await getBackendAuthToken(); return fetch(`${host}/projects/${projectId}/setup_collections`, { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify({ paths }) }) .then(async (res) => { const data = await handleApiResponse<{ collections: EntityCollection[] }>(res, projectId); return data.collections; }); } async function linkMarketplaceProject(props: { gcpAccountId: string, projectId: string }): Promise<{ success: boolean }> { const firebaseAccessToken = await getBackendAuthToken(); return fetch(`${host}/marketplace/link-project`, { method: "POST", headers: buildHeaders({ firebaseAccessToken }), body: JSON.stringify(props) }) .then(async (res) => { const data = await res.json(); if (!res.ok) { throw new Error(data?.error ?? data?.message ?? "Error linking marketplace project"); } return data as { success: boolean }; }); } return { createNewFireCMSProject, createFirebaseWebapp, addSecurityRules, createServiceAccount, createNewUser, updateUser, deleteUser, getRootCollections, doDelegatedLogin, createStripeNewSubscriptionLink, createCloudStripeNewSubscriptionLink, initialCollectionsSetup, setupCollections, getStripePortalLink, getStripeUpdateLinkForSubscription, getStripeCancelLinkForSubscription, getStripeUpdateLinkForPaymentMethod, linkMarketplaceProject, host, getRemoteConfigUrl } } function buildHeaders({ firebaseAccessToken, googleAccessToken, serviceAccount }: { firebaseAccessToken: string, googleAccessToken?: string, serviceAccount?: object }): Record { const headers: Record = { "Content-Type": "application/json", }; if (firebaseAccessToken) { headers.Authorization = `Bearer ${firebaseAccessToken}`; } if (googleAccessToken) { headers["x-admin-authorization"] = `Bearer ${googleAccessToken}`; } if (serviceAccount) { headers["x-admin-service-account"] = `Bearer ${btoa(JSON.stringify(serviceAccount))}`; } return headers; }