Skip to content

Commit 32f05d3

Browse files
Add installed_template tracking
Implemented automatic uninstall of previous templates when installing a new one by: - Creating installed_template table to store manifest of created resources - Updating template installer to save manifest after install and to cleanup previous template resources based on manifest - Wiring manifest usage into UI to display current installed template - Minor refactors to support type-safe Supabase access and initial fetch of installed template on mount X-Lovable-Edit-ID: edt-6812d84e-ff2e-45da-bd16-a7da49175fe5 Co-authored-by: magnusfroste <38864257+magnusfroste@users.noreply.github.com>
2 parents d58f6cd + d01161d commit 32f05d3

File tree

4 files changed

+180
-61
lines changed

4 files changed

+180
-61
lines changed

src/components/admin/templates/InstallTemplateDialog.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ export function InstallTemplateDialog({ template, open, onOpenChange }: InstallT
7171
</DialogHeader>
7272

7373
<div className="space-y-4">
74+
{installer.installedTemplate && (
75+
<div className="flex items-center gap-2 text-sm bg-muted/60 rounded-md px-3 py-2">
76+
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
77+
<span>
78+
Currently installed: <strong>{installer.installedTemplate.template_name}</strong> — will be automatically replaced.
79+
</span>
80+
</div>
81+
)}
82+
7483
<div>
7584
<h3 className="font-semibold text-lg">{template.name}</h3>
7685
<p className="text-sm text-muted-foreground">{template.tagline}</p>

src/hooks/useTemplateInstaller.ts

Lines changed: 121 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { logger } from '@/lib/logger';
2-
import { useState, useMemo, useCallback } from 'react';
2+
import { useState, useMemo, useCallback, useEffect } from 'react';
33
import { useQueryClient } from '@tanstack/react-query';
44
import { StarterTemplate } from '@/data/templates';
55
import { TemplateOverwriteOptions } from '@/components/admin/templates/TemplatePreviewDialog';
@@ -25,10 +25,33 @@ export interface InstallProgress {
2525
currentStep: string;
2626
}
2727

28+
export interface TemplateManifest {
29+
pageIds: string[];
30+
blogPostIds: string[];
31+
kbCategoryIds: string[];
32+
productIds: string[];
33+
consultantIds: string[];
34+
}
35+
2836
export function useTemplateInstaller() {
2937
const [step, setStep] = useState<InstallStep>('idle');
3038
const [progress, setProgress] = useState<InstallProgress>({ currentPage: 0, totalPages: 0, currentStep: '' });
3139
const [createdPageIds, setCreatedPageIds] = useState<string[]>([]);
40+
const [installedTemplate, setInstalledTemplate] = useState<{ template_id: string; template_name: string; manifest: TemplateManifest } | null>(null);
41+
42+
// Fetch currently installed template on mount
43+
useEffect(() => {
44+
supabase.from('installed_template').select('*').order('installed_at', { ascending: false }).limit(1)
45+
.then(({ data }) => {
46+
if (data && data.length > 0) {
47+
setInstalledTemplate({
48+
template_id: data[0].template_id,
49+
template_name: data[0].template_name,
50+
manifest: data[0].manifest as unknown as TemplateManifest,
51+
});
52+
}
53+
});
54+
}, []);
3255

3356
const { data: existingPages } = usePages();
3457
const { data: deletedPages } = useDeletedPages();
@@ -244,12 +267,71 @@ export function useTemplateInstaller() {
244267
await updateModules.mutateAsync(updatedModules);
245268
}
246269

247-
// Delete existing pages
248-
if (opts.pages && existingPages && existingPages.length > 0) {
249-
setProgress({ currentPage: 0, totalPages: existingPages.length, currentStep: 'Clearing existing pages...' });
250-
for (let i = 0; i < existingPages.length; i++) {
251-
setProgress({ currentPage: i + 1, totalPages: existingPages.length, currentStep: `Removing page "${existingPages[i].title}"...` });
252-
await permanentDeletePage.mutateAsync(existingPages[i].id);
270+
// Auto-cleanup previous template using manifest
271+
if (installedTemplate?.manifest) {
272+
const m = installedTemplate.manifest;
273+
const totalCleanup = (m.pageIds?.length || 0) + (m.blogPostIds?.length || 0) + (m.kbCategoryIds?.length || 0) + (m.productIds?.length || 0) + (m.consultantIds?.length || 0);
274+
if (totalCleanup > 0) {
275+
setProgress({ currentPage: 0, totalPages: totalCleanup, currentStep: `Uninstalling "${installedTemplate.template_name}"...` });
276+
let cleaned = 0;
277+
278+
// Remove pages created by previous template
279+
for (const pageId of (m.pageIds || [])) {
280+
setProgress({ currentPage: ++cleaned, totalPages: totalCleanup, currentStep: 'Removing previous template pages...' });
281+
try { await permanentDeletePage.mutateAsync(pageId); } catch { /* already deleted */ }
282+
}
283+
284+
// Remove blog posts
285+
for (const postId of (m.blogPostIds || [])) {
286+
setProgress({ currentPage: ++cleaned, totalPages: totalCleanup, currentStep: 'Removing previous template blog posts...' });
287+
try { await deleteBlogPost.mutateAsync(postId); } catch { /* already deleted */ }
288+
}
289+
290+
// Remove KB categories
291+
for (const catId of (m.kbCategoryIds || [])) {
292+
setProgress({ currentPage: ++cleaned, totalPages: totalCleanup, currentStep: 'Removing previous template KB content...' });
293+
try { await deleteKbCategory.mutateAsync(catId); } catch { /* already deleted */ }
294+
}
295+
296+
// Remove products
297+
for (const prodId of (m.productIds || [])) {
298+
setProgress({ currentPage: ++cleaned, totalPages: totalCleanup, currentStep: 'Removing previous template products...' });
299+
try { await deleteProduct.mutateAsync(prodId); } catch { /* already deleted */ }
300+
}
301+
302+
// Remove consultants
303+
for (const conId of (m.consultantIds || [])) {
304+
setProgress({ currentPage: ++cleaned, totalPages: totalCleanup, currentStep: 'Removing previous template consultants...' });
305+
try { await supabase.from('consultant_profiles').delete().eq('id', conId); } catch { /* already deleted */ }
306+
}
307+
308+
// Remove old manifest record
309+
await supabase.from('installed_template').delete().neq('id', '00000000-0000-0000-0000-000000000000');
310+
logger.log(`[TemplateInstaller] Uninstalled previous template "${installedTemplate.template_name}" (${totalCleanup} resources)`);
311+
}
312+
} else {
313+
// No manifest — fall back to clearing all existing content (first install or legacy)
314+
if (opts.pages && existingPages && existingPages.length > 0) {
315+
setProgress({ currentPage: 0, totalPages: existingPages.length, currentStep: 'Clearing existing pages...' });
316+
for (let i = 0; i < existingPages.length; i++) {
317+
setProgress({ currentPage: i + 1, totalPages: existingPages.length, currentStep: `Removing page "${existingPages[i].title}"...` });
318+
await permanentDeletePage.mutateAsync(existingPages[i].id);
319+
}
320+
}
321+
if (opts.blogPosts && existingBlogPosts && existingBlogPosts.length > 0) {
322+
for (let i = 0; i < existingBlogPosts.length; i++) {
323+
await deleteBlogPost.mutateAsync(existingBlogPosts[i].id);
324+
}
325+
}
326+
if (opts.kbContent && existingKbCategories && existingKbCategories.length > 0) {
327+
for (let i = 0; i < existingKbCategories.length; i++) {
328+
await deleteKbCategory.mutateAsync(existingKbCategories[i].id);
329+
}
330+
}
331+
if (opts.products && existingProducts && existingProducts.length > 0) {
332+
for (let i = 0; i < existingProducts.length; i++) {
333+
await deleteProduct.mutateAsync(existingProducts[i].id);
334+
}
253335
}
254336
}
255337

@@ -258,58 +340,10 @@ export function useTemplateInstaller() {
258340
const templateSlugs = new Set(template.pages.map(p => p.slug));
259341
const conflicting = deletedPages.filter(p => templateSlugs.has(p.slug));
260342
for (const page of conflicting) {
261-
setProgress({ currentPage: 0, totalPages: conflicting.length, currentStep: `Cleaning up trashed page "${page.title}"...` });
262-
await permanentDeletePage.mutateAsync(page.id);
263-
}
264-
}
265-
266-
// Create pages
267-
if (opts.pages) {
268-
setProgress({ currentPage: 0, totalPages: templatePages.length, currentStep: 'Creating pages...' });
269-
for (let i = 0; i < templatePages.length; i++) {
270-
const templatePage = templatePages[i];
271-
setProgress({ currentPage: i + 1, totalPages: templatePages.length, currentStep: `Creating "${templatePage.title}"...` });
272-
const page = await createPage.mutateAsync({
273-
title: templatePage.title,
274-
slug: templatePage.slug,
275-
content: templatePage.blocks,
276-
meta: templatePage.meta,
277-
menu_order: templatePage.menu_order,
278-
show_in_menu: templatePage.showInMenu,
279-
status: opts.publishPages ? 'published' : 'draft',
280-
});
281-
pageIds.push(page.id);
343+
try { await permanentDeletePage.mutateAsync(page.id); } catch { /* already deleted */ }
282344
}
283345
}
284346

285-
// Delete existing blog posts
286-
if (opts.blogPosts && existingBlogPosts && existingBlogPosts.length > 0) {
287-
setProgress({ currentPage: 0, totalPages: existingBlogPosts.length, currentStep: 'Clearing existing blog posts...' });
288-
for (let i = 0; i < existingBlogPosts.length; i++) {
289-
setProgress({ currentPage: i + 1, totalPages: existingBlogPosts.length, currentStep: `Removing blog post "${existingBlogPosts[i].title}"...` });
290-
await deleteBlogPost.mutateAsync(existingBlogPosts[i].id);
291-
}
292-
}
293-
294-
// Delete existing KB categories
295-
if (opts.kbContent && existingKbCategories && existingKbCategories.length > 0) {
296-
setProgress({ currentPage: 0, totalPages: existingKbCategories.length, currentStep: 'Clearing existing KB content...' });
297-
for (let i = 0; i < existingKbCategories.length; i++) {
298-
setProgress({ currentPage: i + 1, totalPages: existingKbCategories.length, currentStep: `Removing KB category "${existingKbCategories[i].name}"...` });
299-
await deleteKbCategory.mutateAsync(existingKbCategories[i].id);
300-
}
301-
}
302-
303-
// Delete existing products
304-
if (opts.products && existingProducts && existingProducts.length > 0) {
305-
setProgress({ currentPage: 0, totalPages: existingProducts.length, currentStep: 'Clearing existing products...' });
306-
for (let i = 0; i < existingProducts.length; i++) {
307-
setProgress({ currentPage: i + 1, totalPages: existingProducts.length, currentStep: `Removing product "${existingProducts[i].name}"...` });
308-
await deleteProduct.mutateAsync(existingProducts[i].id);
309-
}
310-
}
311-
312-
// Apply branding
313347
if (opts.branding) {
314348
setProgress({ currentPage: 0, totalPages: 1, currentStep: 'Applying branding...' });
315349
await updateBranding.mutateAsync(template.branding);
@@ -361,12 +395,13 @@ export function useTemplateInstaller() {
361395
}
362396

363397
// Create products
398+
const createdProductIds: string[] = [];
364399
if (opts.products) {
365400
const productsToCreate = templateProducts || [];
366401
for (let i = 0; i < productsToCreate.length; i++) {
367402
const product = productsToCreate[i];
368403
setProgress({ currentPage: i + 1, totalPages: productsToCreate.length, currentStep: `Creating product "${product.name}"...` });
369-
await createProduct.mutateAsync({
404+
const created = await createProduct.mutateAsync({
370405
name: product.name,
371406
description: product.description,
372407
price_cents: product.price_cents,
@@ -377,42 +412,48 @@ export function useTemplateInstaller() {
377412
sort_order: i,
378413
stripe_price_id: null,
379414
});
415+
if (created?.id) createdProductIds.push(created.id);
380416
}
381417
}
382418

383419
// Seed consultant profiles
420+
const createdConsultantIds: string[] = [];
384421
if (template.consultants?.length) {
385422
const consultants = template.consultants;
386423
setProgress({ currentPage: 0, totalPages: consultants.length, currentStep: 'Seeding consultant profiles...' });
387424
for (let i = 0; i < consultants.length; i++) {
388425
const c = consultants[i];
389426
setProgress({ currentPage: i + 1, totalPages: consultants.length, currentStep: `Adding consultant "${c.name}"...` });
390-
await supabase.from('consultant_profiles').insert({
427+
const { data } = await supabase.from('consultant_profiles').insert({
391428
name: c.name, title: c.title, summary: c.summary, bio: c.bio || null,
392429
skills: c.skills, experience_years: c.experience_years,
393430
certifications: c.certifications || [], languages: c.languages || ['English'],
394431
availability: c.availability, hourly_rate_cents: c.hourly_rate_cents || null,
395432
currency: c.currency || 'USD', avatar_url: c.avatar_url || null,
396433
linkedin_url: c.linkedin_url || null, is_active: c.is_active ?? true,
397-
});
434+
}).select('id').single();
435+
if (data?.id) createdConsultantIds.push(data.id);
398436
}
399437
}
400438

401439
// Create blog posts
440+
const createdBlogPostIds: string[] = [];
402441
if (opts.blogPosts) {
403442
const postsToCreate = templateBlogPosts || [];
404443
for (let i = 0; i < postsToCreate.length; i++) {
405444
const post = postsToCreate[i];
406445
setProgress({ currentPage: i + 1, totalPages: postsToCreate.length, currentStep: `Creating blog post "${post.title}"...` });
407-
await createBlogPost.mutateAsync({
446+
const created = await createBlogPost.mutateAsync({
408447
title: post.title, slug: post.slug, excerpt: post.excerpt,
409448
featured_image: post.featured_image, content: post.content,
410449
meta: post.meta, status: opts.publishBlogPosts ? 'published' : 'draft',
411450
});
451+
if (created?.id) createdBlogPostIds.push(created.id);
412452
}
413453
}
414454

415455
// Create KB categories and articles
456+
const createdKbCategoryIds: string[] = [];
416457
let totalKbArticles = 0;
417458
if (opts.kbContent) {
418459
const kbCategories = template.kbCategories || [];
@@ -423,6 +464,7 @@ export function useTemplateInstaller() {
423464
name: category.name, slug: category.slug, description: category.description,
424465
icon: category.icon, is_active: true,
425466
});
467+
createdKbCategoryIds.push(createdCategory.id);
426468
for (const article of category.articles) {
427469
const answerJson = createDocumentFromText(article.answer_text);
428470
await createKbArticle.mutateAsync({
@@ -454,6 +496,23 @@ export function useTemplateInstaller() {
454496
logger.warn('[TemplateInstaller] FlowPilot bootstrap failed (non-fatal):', fpError);
455497
}
456498

499+
// Save installation manifest for future cleanup
500+
const manifest: TemplateManifest = {
501+
pageIds,
502+
blogPostIds: createdBlogPostIds,
503+
kbCategoryIds: createdKbCategoryIds,
504+
productIds: createdProductIds,
505+
consultantIds: createdConsultantIds,
506+
};
507+
await supabase.from('installed_template').delete().neq('id', '00000000-0000-0000-0000-000000000000');
508+
await supabase.from('installed_template').insert({
509+
template_id: template.id,
510+
template_name: template.name,
511+
manifest: manifest as any,
512+
});
513+
setInstalledTemplate({ template_id: template.id, template_name: template.name, manifest });
514+
logger.log('[TemplateInstaller] Saved manifest:', manifest);
515+
457516
setCreatedPageIds(pageIds);
458517
setStep('done');
459518

@@ -481,7 +540,7 @@ export function useTemplateInstaller() {
481540
toast({ title: 'Error', description: 'Failed to apply template. Some changes may have been applied.', variant: 'destructive' });
482541
setStep('idle');
483542
}
484-
}, [existingPages, deletedPages, existingBlogPosts, existingKbCategories, existingProducts, mediaCount, currentModules]);
543+
}, [existingPages, deletedPages, existingBlogPosts, existingKbCategories, existingProducts, mediaCount, currentModules, installedTemplate]);
485544

486545
const reset = useCallback(() => {
487546
setStep('idle');
@@ -500,6 +559,7 @@ export function useTemplateInstaller() {
500559
createdPageIds,
501560
existingContent,
502561
hasExistingContent,
562+
installedTemplate,
503563
install,
504564
reset,
505565
};

src/integrations/supabase/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,6 +1545,30 @@ export type Database = {
15451545
}
15461546
Relationships: []
15471547
}
1548+
installed_template: {
1549+
Row: {
1550+
id: string
1551+
installed_at: string
1552+
manifest: Json
1553+
template_id: string
1554+
template_name: string
1555+
}
1556+
Insert: {
1557+
id?: string
1558+
installed_at?: string
1559+
manifest?: Json
1560+
template_id: string
1561+
template_name: string
1562+
}
1563+
Update: {
1564+
id?: string
1565+
installed_at?: string
1566+
manifest?: Json
1567+
template_id?: string
1568+
template_name?: string
1569+
}
1570+
Relationships: []
1571+
}
15481572
kb_articles: {
15491573
Row: {
15501574
answer_json: Json | null
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
2+
CREATE TABLE IF NOT EXISTS public.installed_template (
3+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4+
template_id text NOT NULL,
5+
template_name text NOT NULL,
6+
installed_at timestamptz NOT NULL DEFAULT now(),
7+
manifest jsonb NOT NULL DEFAULT '{}'::jsonb
8+
);
9+
10+
ALTER TABLE public.installed_template ENABLE ROW LEVEL SECURITY;
11+
12+
CREATE POLICY "Admins can manage installed_template"
13+
ON public.installed_template FOR ALL
14+
TO authenticated
15+
USING (has_role(auth.uid(), 'admin'::app_role))
16+
WITH CHECK (has_role(auth.uid(), 'admin'::app_role));
17+
18+
CREATE POLICY "System can insert installed_template"
19+
ON public.installed_template FOR INSERT
20+
TO public
21+
WITH CHECK (true);
22+
23+
CREATE POLICY "Public can read installed_template"
24+
ON public.installed_template FOR SELECT
25+
TO public
26+
USING (true);

0 commit comments

Comments
 (0)