feat: Add event automation system for plugins attached to appointments

- Add GlobalEventPlugin model for auto-attaching plugins to all events
- Create signals for auto-attachment on new events and rescheduling
- Add API endpoints for global event plugins (CRUD, toggle, reapply)
- Update CreateTaskModal with "Scheduled Task" vs "Event Automation" choice
- Add option to apply to all events or future events only
- Display event automations in Tasks page alongside scheduled tasks
- Add EditEventAutomationModal for editing trigger and timing
- Handle event reschedule - update Celery task timing on time/duration changes
- Add Marketplace to Plugins menu in sidebar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-29 12:17:28 -05:00
parent 9b106bf129
commit 0c7d76e264
18 changed files with 3061 additions and 175 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ShoppingBag,
@@ -19,7 +20,10 @@ import {
Package,
Eye,
ChevronDown,
AlertTriangle
AlertTriangle,
Clock,
Settings,
ArrowRight
} from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
@@ -76,6 +80,7 @@ const detectPlatformOnlyCode = (code: string): { hasPlatformCode: boolean; warni
const PluginMarketplace: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
@@ -83,6 +88,8 @@ const PluginMarketplace: React.FC = () => {
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showCode, setShowCode] = useState(false);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [showWhatsNextModal, setShowWhatsNextModal] = useState(false);
const [installedPluginId, setInstalledPluginId] = useState<string | null>(null);
// Fetch marketplace plugins
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
@@ -109,6 +116,20 @@ const PluginMarketplace: React.FC = () => {
},
});
// Fetch installed plugins to check which are already installed
const { data: installedPlugins = [] } = useQuery<{ template: number }[]>({
queryKey: ['plugin-installations'],
queryFn: async () => {
const { data } = await api.get('/api/plugin-installations/');
return data;
},
});
// Create a set of installed template IDs for quick lookup
const installedTemplateIds = useMemo(() => {
return new Set(installedPlugins.map(p => String(p.template)));
}, [installedPlugins]);
// Install plugin mutation
const installMutation = useMutation({
mutationFn: async (templateId: string) => {
@@ -117,10 +138,12 @@ const PluginMarketplace: React.FC = () => {
});
return data;
},
onSuccess: () => {
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
setShowDetailsModal(false);
setSelectedPlugin(null);
// Show What's Next modal
setInstalledPluginId(data.id);
setShowWhatsNextModal(true);
},
});
@@ -322,6 +345,12 @@ const PluginMarketplace: React.FC = () => {
Featured
</span>
)}
{installedTemplateIds.has(plugin.id) && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
<CheckCircle className="h-3 w-3" />
Installed
</span>
)}
</div>
{plugin.isVerified && (
<CheckCircle className="h-5 w-5 text-blue-500 flex-shrink-0" title="Verified" />
@@ -550,24 +579,123 @@ const PluginMarketplace: React.FC = () => {
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
>
{t('common.cancel', 'Cancel')}
{installedTemplateIds.has(selectedPlugin.id) ? t('common.close', 'Close') : t('common.cancel', 'Cancel')}
</button>
{installedTemplateIds.has(selectedPlugin.id) ? (
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedPlugin(null);
setShowCode(false);
navigate('/plugins/my-plugins');
}}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
<CheckCircle className="h-4 w-4" />
{t('plugins.viewInstalled', 'View in My Plugins')}
</button>
) : (
<button
onClick={confirmInstall}
disabled={installMutation.isPending || isLoadingDetails}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{installMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('plugins.installing', 'Installing...')}
</>
) : (
<>
<Download className="h-4 w-4" />
{t('plugins.install', 'Install')}
</>
)}
</button>
)}
</div>
</div>
</div>
)}
{/* What's Next Modal - shown after successful install */}
{showWhatsNextModal && selectedPlugin && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full shadow-2xl overflow-hidden">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-8 text-center">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-10 h-10 text-white" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">
Plugin Installed!
</h2>
<p className="text-green-100">
{selectedPlugin.name} is ready to use
</p>
</div>
{/* What's Next Options */}
<div className="p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
What would you like to do?
</h3>
{/* Schedule Task Option */}
<button
onClick={confirmInstall}
disabled={installMutation.isPending || isLoadingDetails}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
navigate('/tasks');
}}
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors text-left group"
>
{installMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('plugins.installing', 'Installing...')}
</>
) : (
<>
<Download className="h-4 w-4" />
{t('plugins.install', 'Install')}
</>
)}
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Clock className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white">
Schedule it
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Run automatically on a schedule (hourly, daily, etc.)
</p>
</div>
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors" />
</button>
{/* Configure Option */}
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
navigate('/plugins/my-plugins');
}}
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors text-left group"
>
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Settings className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white">
Configure settings
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Set up plugin options and customize behavior
</p>
</div>
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors" />
</button>
{/* Done Option */}
<button
onClick={() => {
setShowWhatsNextModal(false);
setSelectedPlugin(null);
}}
className="w-full p-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors text-center"
>
Done for now
</button>
</div>
</div>