@bigdigital/kiosk-content-sdk
Version:
A Firebase-powered Content Management System SDK optimized for kiosks with offline support, template management, and real-time connection monitoring
387 lines (327 loc) • 11.7 kB
text/typescript
import { initializeApp, getApp, FirebaseApp } from "firebase/app";
import {
getFirestore,
collection,
query,
where,
getDocs,
doc,
getDoc,
Firestore
} from "firebase/firestore";
import type {
KioskConfig,
Content,
Template,
Field,
Group,
GroupedTemplateContent,
TemplateContentStructure,
TemplateValues
} from "./types";
import { templateSchema, contentSchema, fieldSchema } from "./types";
// Helper function to find field by id
function findFieldById(fields: Field[], fieldId: string): Field | undefined {
return fields.find(field => field.id === fieldId);
}
// Normalize group names to proper camelCase
function normalizeGroupName(name: string): string {
let words = name.split(/[\s\-_]+/);
if (words.length === 1) {
words = words[0]
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([a-zA-Z])(\d)/g, '$1 $2')
.replace(/([a-z])([a-z]*)/gi, (match, first, rest) => `${first}${rest.toLowerCase()}`)
.split(/\s+/);
}
words = words
.filter(word => word.length > 0)
.map(word => word.toLowerCase());
if (words.length === 0) return '';
return words[0] + words.slice(1)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
}
function processTemplateValues(
template: Template,
rawData: Record<string, any> | undefined,
content: Content
): TemplateValues {
console.log("Raw content being processed:", rawData);
console.log("Template being used:", {
id: template.id,
name: template.name,
fields: template.fields,
groups: template.groups
});
const result: TemplateValues = {
groups: {},
ungrouped: {}
};
if (!rawData) {
console.log('No raw data provided');
return result;
}
// First, try to get values from templateValues if they exist
let existingValues = rawData.templateValues || {};
console.log("Existing template values:", existingValues);
// Process fields in groups
Object.entries(template.groups || {}).forEach(([groupId, group]) => {
// Always generate and use the proper camelCase name
const normalizedName = normalizeGroupName(group.name);
console.log(`Processing group ${group.name} -> ${normalizedName}`);
// Store the normalizedName back to the group for consistency
group.normalizedName = normalizedName;
// Initialize group
result.groups[normalizedName] = {};
// Check multiple locations for values in order of precedence
const groupValues = existingValues.groups?.[normalizedName] ||
existingValues.groups?.[group.name] ||
rawData[normalizedName] ||
rawData[group.name] ||
{};
console.log(`Group values found for ${normalizedName}:`, groupValues);
// Process fields in this group
group.fieldIds.forEach(fieldId => {
const field = findFieldById(template.fields, fieldId);
if (field) {
const normalizedFieldName = field.name.charAt(0).toLowerCase() + field.name.slice(1);
// Try to get value from various locations
const fieldValue = groupValues[normalizedFieldName] ||
groupValues[field.name] ||
rawData[normalizedFieldName] ||
rawData[field.name] ||
field.defaultValue ||
null;
console.log(`Field ${field.name} -> ${normalizedFieldName} in group ${normalizedName}:`, {
value: fieldValue,
source: groupValues[normalizedFieldName] ? 'normalized group' :
groupValues[field.name] ? 'original group' :
rawData[normalizedFieldName] ? 'normalized root' :
rawData[field.name] ? 'original root' :
field.defaultValue ? 'default' : 'null'
});
result.groups[normalizedName][normalizedFieldName] = fieldValue;
}
});
});
// Process ungrouped fields
const groupedFieldIds = new Set(
Object.values(template.groups || {})
.flatMap(group => group.fieldIds)
);
template.fields.forEach(field => {
if (!groupedFieldIds.has(field.id)) {
const normalizedFieldName = field.name.charAt(0).toLowerCase() + field.name.slice(1);
result.ungrouped[normalizedFieldName] = rawData[normalizedFieldName] ||
rawData[field.name] ||
existingValues.ungrouped?.[normalizedFieldName] ||
existingValues.ungrouped?.[field.name] ||
field.defaultValue ||
null;
}
});
console.log("Final processed template values:", result);
return result;
}
export class KioskClient {
private app: FirebaseApp;
private db: Firestore;
constructor(config: KioskConfig) {
try {
this.app = getApp();
} catch {
this.app = initializeApp({
apiKey: config.apiKey,
authDomain: config.authDomain || `${config.projectId}.firebaseapp.com`,
projectId: config.projectId,
});
}
this.db = getFirestore(this.app);
}
async getTemplates(): Promise<Template[]> {
const templatesRef = collection(this.db, "templates");
const snapshot = await getDocs(templatesRef);
const templates = snapshot.docs.map(doc => {
const data = doc.data();
// Ensure fields is an array
if (!Array.isArray(data.fields)) {
data.fields = Object.values(data.fields);
}
// Ensure all groups have normalizedName in camelCase
if (data.groups) {
Object.values(data.groups).forEach((group: any) => {
group.normalizedName = normalizeGroupName(group.name);
});
}
return { id: doc.id, ...data } as Template;
});
return templates;
}
async getTemplateById(templateId: string): Promise<Template | null> {
const docRef = doc(this.db, "templates", templateId);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) {
console.log(`Template ${templateId} not found`);
return null;
}
const data = docSnap.data();
// Ensure fields is an array
if (!Array.isArray(data.fields)) {
data.fields = Object.values(data.fields);
}
// Ensure all groups have normalizedName in camelCase
if (data.groups) {
Object.values(data.groups).forEach((group: any) => {
group.normalizedName = normalizeGroupName(group.name);
});
}
const template = { id: docSnap.id, ...data } as Template;
console.log(`Retrieved template ${templateId}:`, template);
return template;
}
async getPublishedContent(): Promise<Content[]> {
const templates = await this.getTemplates();
console.log(`Retrieved ${templates.length} templates`);
const contentRef = collection(this.db, "contents");
const publishedQuery = query(contentRef, where("published", "==", true));
const snapshot = await getDocs(publishedQuery);
console.log(`Found ${snapshot.size} published content items`);
return Promise.all(snapshot.docs.map(async doc => {
const rawData = doc.data();
console.log(`Processing content ${doc.id}:`, {
templateId: rawData.templateId,
rawValues: rawData
});
console.log("Raw content data from Firestore:", rawData);
const content = {
id: doc.id,
...rawData,
templateValues: {
groups: {},
ungrouped: {}
}
} as Content;
if (content.templateId) {
const template = templates.find(t => t.id === content.templateId);
if (template) {
const templateValues = processTemplateValues(template, rawData, content);
console.log("Final processed template values:", templateValues);
content.templateValues = templateValues;
console.log(`Processed content ${doc.id} template values:`, content.templateValues);
}
}
return content;
}));
}
async getContentByProject(projectId: string): Promise<Content[]> {
const templates = await this.getTemplates();
const contentRef = collection(this.db, "contents");
const projectQuery = query(
contentRef,
where("published", "==", true),
where("projectIds", "array-contains", projectId)
);
const snapshot = await getDocs(projectQuery);
return Promise.all(
snapshot.docs.map(async (doc) => {
const rawData = doc.data();
const content = {
id: doc.id,
...rawData,
templateValues: {
groups: {},
ungrouped: {}
}
} as Content;
if (content.templateId) {
const template = templates.find(t => t.id === content.templateId);
if (template) {
content.templateValues = processTemplateValues(template, rawData, content);
}
}
return content;
})
);
}
async getContentWithTemplate(contentId: string): Promise<{
content: Content;
template: Template | null;
groupedContent?: Promise<TemplateContentStructure>;
}> {
const docRef = doc(this.db, "contents", contentId);
const docSnap = await getDoc(docRef);
if (!docSnap.exists()) {
throw new Error("Content not found");
}
const rawData = docSnap.data();
const content = {
id: docSnap.id,
...rawData,
templateValues: {
groups: {},
ungrouped: {}
}
} as Content;
if (!content.templateId) {
return { content, template: null };
}
const template = await this.getTemplateById(content.templateId);
if (!template) {
return { content, template: null };
}
content.templateValues = processTemplateValues(template, rawData, content);
return {
content,
template,
groupedContent: this.getGroupedTemplateContent(template, content)
};
}
async getGroupedTemplateContent(
template: Template,
content: Content
): Promise<TemplateContentStructure> {
const templateValues = content.templateValues ?? { groups: {}, ungrouped: {} };
const groups = (template.groupOrder || [])
.map(groupId => {
const group = template.groups[groupId];
if (!group) return null;
const normalizedName = normalizeGroupName(group.name);
const fields = group.fieldIds
.map(fieldId => findFieldById(template.fields, fieldId))
.filter((field): field is Field => field !== undefined);
return {
group: {
...group,
normalizedName,
},
fields,
values: templateValues.groups[normalizedName] ?? {}
};
})
.filter((group): group is GroupedTemplateContent => group !== null);
const ungroupedFieldIds = template.fields
.filter(field => !Object.values(template.groups)
.some(group => group.fieldIds?.includes(field.id)))
.map(field => field);
return {
groups,
ungroupedFields: {
fields: ungroupedFieldIds,
values: templateValues.ungrouped ?? {}
}
};
}
}
export function isValidTemplate(template: unknown): template is Template {
const result = templateSchema.safeParse(template);
return result.success;
}
export function isValidContent(content: unknown): content is Content {
const result = contentSchema.safeParse(content);
return result.success;
}
export function validateTemplateField(field: unknown): field is Field {
const result = fieldSchema.safeParse(field);
return result.success;
}