@realtimex/email-automator 2.11.3 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/rulePacks.ts +204 -0
- package/api/src/services/RulePackService.ts +354 -0
- package/api/src/services/gmail.ts +68 -0
- package/api/src/services/microsoft.ts +99 -0
- package/api/src/services/processor.ts +137 -10
- package/api/src/services/rulePacks/developer.ts +179 -0
- package/api/src/services/rulePacks/executive.ts +143 -0
- package/api/src/services/rulePacks/index.ts +63 -0
- package/api/src/services/rulePacks/operations.ts +183 -0
- package/api/src/services/rulePacks/sales.ts +160 -0
- package/api/src/services/rulePacks/types.ts +116 -0
- package/api/src/services/rulePacks/universal.ts +83 -0
- package/api/src/services/supabase.ts +40 -0
- package/dist/api/src/routes/index.js +2 -0
- package/dist/api/src/routes/rulePacks.js +179 -0
- package/dist/api/src/services/RulePackService.js +296 -0
- package/dist/api/src/services/gmail.js +56 -0
- package/dist/api/src/services/microsoft.js +79 -0
- package/dist/api/src/services/processor.js +125 -10
- package/dist/api/src/services/rulePacks/developer.js +176 -0
- package/dist/api/src/services/rulePacks/executive.js +140 -0
- package/dist/api/src/services/rulePacks/index.js +58 -0
- package/dist/api/src/services/rulePacks/operations.js +180 -0
- package/dist/api/src/services/rulePacks/sales.js +157 -0
- package/dist/api/src/services/rulePacks/types.js +7 -0
- package/dist/api/src/services/rulePacks/universal.js +80 -0
- package/dist/assets/index-B5rXh3y8.css +1 -0
- package/dist/assets/index-Cn89wpYc.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/supabase/migrations/20260131000000_add_rule_packs.sql +176 -0
- package/supabase/migrations/20260131000001_set_zero_config_defaults.sql +51 -0
- package/supabase/migrations/20260131095000_backfill_user_settings.sql +36 -0
- package/supabase/migrations/20260131100000_rule_templates_table.sql +154 -0
- package/supabase/migrations/20260131110000_auto_init_user_data.sql +90 -0
- package/supabase/migrations/20260131120000_backfill_universal_pack.sql +84 -0
- package/supabase/migrations/20260131130000_simplify_rules_with_categories.sql +87 -0
- package/supabase/migrations/20260131140000_fix_action_constraint.sql +11 -0
- package/supabase/migrations/20260131150000_fix_trigger_error_handling.sql +71 -0
- package/supabase/migrations/20260131160000_enable_intelligent_rename_by_default.sql +18 -0
- package/supabase/migrations/20260131170000_fix_rule_categories.sql +140 -0
- package/dist/assets/index-BgMWzGP9.js +0 -105
- package/dist/assets/index-CtDzSy0n.css +0 -1
package/api/src/routes/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import settingsRoutes from './settings.js';
|
|
|
8
8
|
import emailsRoutes from './emails.js';
|
|
9
9
|
import migrateRoutes from './migrate.js';
|
|
10
10
|
import sdkRoutes from './sdk.js';
|
|
11
|
+
import rulePacksRoutes from './rulePacks.js';
|
|
11
12
|
|
|
12
13
|
const router = Router();
|
|
13
14
|
|
|
@@ -20,5 +21,6 @@ router.use('/settings', settingsRoutes);
|
|
|
20
21
|
router.use('/emails', emailsRoutes);
|
|
21
22
|
router.use('/migrate', migrateRoutes);
|
|
22
23
|
router.use('/sdk', sdkRoutes);
|
|
24
|
+
router.use('/rule-packs', rulePacksRoutes);
|
|
23
25
|
|
|
24
26
|
export default router;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule Pack Routes - Zero-Configuration UX
|
|
3
|
+
*
|
|
4
|
+
* Endpoints for managing rule pack installations and user role selection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router, Request, Response } from 'express';
|
|
8
|
+
import { createLogger } from '../utils/logger.js';
|
|
9
|
+
import { RulePackService } from '../services/RulePackService.js';
|
|
10
|
+
import { getAllPacks, getPackById, getPacksForRole } from '../services/rulePacks/index.js';
|
|
11
|
+
import type { UserRole } from '../services/rulePacks/types.js';
|
|
12
|
+
|
|
13
|
+
const router = Router();
|
|
14
|
+
const logger = createLogger('RulePackRoutes');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GET /api/rule-packs
|
|
18
|
+
* List all available rule packs
|
|
19
|
+
*/
|
|
20
|
+
router.get('/', async (req: Request, res: Response) => {
|
|
21
|
+
try {
|
|
22
|
+
const packs = getAllPacks();
|
|
23
|
+
|
|
24
|
+
res.json({
|
|
25
|
+
success: true,
|
|
26
|
+
packs: packs.map(pack => ({
|
|
27
|
+
id: pack.id,
|
|
28
|
+
name: pack.name,
|
|
29
|
+
description: pack.description,
|
|
30
|
+
icon: pack.icon,
|
|
31
|
+
ruleCount: pack.rules.length,
|
|
32
|
+
targetRoles: pack.target_roles
|
|
33
|
+
}))
|
|
34
|
+
});
|
|
35
|
+
} catch (error: any) {
|
|
36
|
+
logger.error('Failed to list packs', error);
|
|
37
|
+
res.status(500).json({ success: false, error: error.message });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* GET /api/rule-packs/:packId
|
|
43
|
+
* Get details of a specific pack
|
|
44
|
+
*/
|
|
45
|
+
router.get('/:packId', async (req: Request, res: Response) => {
|
|
46
|
+
try {
|
|
47
|
+
const packId = req.params.packId as string;
|
|
48
|
+
const pack = getPackById(packId);
|
|
49
|
+
|
|
50
|
+
if (!pack) {
|
|
51
|
+
return res.status(404).json({ success: false, error: 'Pack not found' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
res.json({ success: true, pack });
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
logger.error('Failed to get pack', error);
|
|
57
|
+
res.status(500).json({ success: false, error: error.message });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* POST /api/rule-packs/install
|
|
63
|
+
* Install a rule pack for the authenticated user
|
|
64
|
+
* Body: { packId: string }
|
|
65
|
+
*/
|
|
66
|
+
router.post('/install', async (req: Request, res: Response) => {
|
|
67
|
+
try {
|
|
68
|
+
const { packId } = req.body;
|
|
69
|
+
const supabase = (req as any).supabase;
|
|
70
|
+
const userId = (req as any).userId;
|
|
71
|
+
|
|
72
|
+
if (!packId) {
|
|
73
|
+
return res.status(400).json({ success: false, error: 'packId is required' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!userId) {
|
|
77
|
+
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const service = new RulePackService(supabase);
|
|
81
|
+
const result = await service.installPack(userId, packId);
|
|
82
|
+
|
|
83
|
+
res.json(result);
|
|
84
|
+
} catch (error: any) {
|
|
85
|
+
logger.error('Failed to install pack', error);
|
|
86
|
+
res.status(500).json({ success: false, error: error.message });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* POST /api/rule-packs/uninstall
|
|
92
|
+
* Uninstall a rule pack
|
|
93
|
+
* Body: { packId: string }
|
|
94
|
+
*/
|
|
95
|
+
router.post('/uninstall', async (req: Request, res: Response) => {
|
|
96
|
+
try {
|
|
97
|
+
const { packId } = req.body;
|
|
98
|
+
const supabase = (req as any).supabase;
|
|
99
|
+
const userId = (req as any).userId;
|
|
100
|
+
|
|
101
|
+
if (!packId) {
|
|
102
|
+
return res.status(400).json({ success: false, error: 'packId is required' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!userId) {
|
|
106
|
+
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const service = new RulePackService(supabase);
|
|
110
|
+
const result = await service.uninstallPack(userId, packId);
|
|
111
|
+
|
|
112
|
+
res.json(result);
|
|
113
|
+
} catch (error: any) {
|
|
114
|
+
logger.error('Failed to uninstall pack', error);
|
|
115
|
+
res.status(500).json({ success: false, error: error.message });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* GET /api/rule-packs/installed
|
|
121
|
+
* Get user's installed packs
|
|
122
|
+
*/
|
|
123
|
+
router.get('/installed', async (req: Request, res: Response) => {
|
|
124
|
+
try {
|
|
125
|
+
const supabase = (req as any).supabase;
|
|
126
|
+
const userId = (req as any).userId;
|
|
127
|
+
|
|
128
|
+
if (!userId) {
|
|
129
|
+
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const service = new RulePackService(supabase);
|
|
133
|
+
const installations = await service.getInstalledPacks(userId);
|
|
134
|
+
|
|
135
|
+
res.json({ success: true, installations });
|
|
136
|
+
} catch (error: any) {
|
|
137
|
+
logger.error('Failed to get installed packs', error);
|
|
138
|
+
res.status(500).json({ success: false, error: error.message });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* POST /api/rule-packs/set-role
|
|
144
|
+
* Set user role and install appropriate packs
|
|
145
|
+
* Body: { role: UserRole }
|
|
146
|
+
*/
|
|
147
|
+
router.post('/set-role', async (req: Request, res: Response) => {
|
|
148
|
+
try {
|
|
149
|
+
const { role } = req.body;
|
|
150
|
+
const supabase = (req as any).supabase;
|
|
151
|
+
const userId = (req as any).userId;
|
|
152
|
+
|
|
153
|
+
if (!role) {
|
|
154
|
+
return res.status(400).json({ success: false, error: 'role is required' });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!userId) {
|
|
158
|
+
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const validRoles: UserRole[] = ['executive', 'developer', 'sales', 'operations', 'marketing', 'other'];
|
|
162
|
+
if (!validRoles.includes(role as UserRole)) {
|
|
163
|
+
return res.status(400).json({ success: false, error: 'Invalid role' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const service = new RulePackService(supabase);
|
|
167
|
+
const result = await service.updateUserRole(userId, role as UserRole, 'onboarding');
|
|
168
|
+
|
|
169
|
+
logger.info(`User ${userId} set role to ${role}, installed ${result.packsInstalled.length} packs`);
|
|
170
|
+
|
|
171
|
+
res.json(result);
|
|
172
|
+
} catch (error: any) {
|
|
173
|
+
logger.error('Failed to set user role', error);
|
|
174
|
+
res.status(500).json({ success: false, error: error.message });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* GET /api/rule-packs/for-role/:role
|
|
180
|
+
* Get recommended packs for a specific role
|
|
181
|
+
*/
|
|
182
|
+
router.get('/for-role/:role', async (req: Request, res: Response) => {
|
|
183
|
+
try {
|
|
184
|
+
const { role } = req.params;
|
|
185
|
+
const packs = getPacksForRole(role as UserRole);
|
|
186
|
+
|
|
187
|
+
res.json({
|
|
188
|
+
success: true,
|
|
189
|
+
role,
|
|
190
|
+
packs: packs.map(pack => ({
|
|
191
|
+
id: pack.id,
|
|
192
|
+
name: pack.name,
|
|
193
|
+
description: pack.description,
|
|
194
|
+
icon: pack.icon,
|
|
195
|
+
ruleCount: pack.rules.length
|
|
196
|
+
}))
|
|
197
|
+
});
|
|
198
|
+
} catch (error: any) {
|
|
199
|
+
logger.error('Failed to get packs for role', error);
|
|
200
|
+
res.status(500).json({ success: false, error: error.message });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
export default router;
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule Pack Service - Zero-Configuration Email Automation
|
|
3
|
+
*
|
|
4
|
+
* Handles installation, management, and tracking of rule packs for users.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
8
|
+
import { createLogger } from '../utils/logger.js';
|
|
9
|
+
import { getPackById, getPacksForRole, ALL_PACKS } from './rulePacks/index.js';
|
|
10
|
+
import type { RulePack, RuleTemplate, UserRole } from './rulePacks/types.js';
|
|
11
|
+
import type { Rule, PackInstallation } from './supabase.js';
|
|
12
|
+
|
|
13
|
+
const logger = createLogger('RulePackService');
|
|
14
|
+
|
|
15
|
+
export class RulePackService {
|
|
16
|
+
constructor(private supabase: SupabaseClient) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Install a rule pack for a user
|
|
20
|
+
* Creates all rules from the pack and tracks installation
|
|
21
|
+
*/
|
|
22
|
+
async installPack(userId: string, packId: string): Promise<{ success: boolean; rulesCreated: number; error?: string }> {
|
|
23
|
+
try {
|
|
24
|
+
const pack = getPackById(packId);
|
|
25
|
+
if (!pack) {
|
|
26
|
+
return { success: false, rulesCreated: 0, error: `Pack '${packId}' not found` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
logger.info(`Installing pack '${packId}' for user ${userId}`);
|
|
30
|
+
|
|
31
|
+
// Check if pack is already installed
|
|
32
|
+
const { data: existing } = await this.supabase
|
|
33
|
+
.from('pack_installations')
|
|
34
|
+
.select('*')
|
|
35
|
+
.eq('user_id', userId)
|
|
36
|
+
.eq('pack_id', packId)
|
|
37
|
+
.is('uninstalled_at', null)
|
|
38
|
+
.single();
|
|
39
|
+
|
|
40
|
+
if (existing) {
|
|
41
|
+
logger.info(`Pack '${packId}' already installed for user ${userId}`);
|
|
42
|
+
return { success: true, rulesCreated: 0 }; // Already installed, no-op
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Create all rules from the pack
|
|
46
|
+
let rulesCreated = 0;
|
|
47
|
+
for (const template of pack.rules) {
|
|
48
|
+
// Check if rule already exists (by rule_template_id)
|
|
49
|
+
const { data: existingRule } = await this.supabase
|
|
50
|
+
.from('rules')
|
|
51
|
+
.select('id')
|
|
52
|
+
.eq('user_id', userId)
|
|
53
|
+
.eq('rule_template_id', template.id)
|
|
54
|
+
.single();
|
|
55
|
+
|
|
56
|
+
if (existingRule) {
|
|
57
|
+
logger.debug(`Rule '${template.id}' already exists, skipping`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Create rule from template
|
|
62
|
+
const { error: ruleError } = await this.supabase
|
|
63
|
+
.from('rules')
|
|
64
|
+
.insert({
|
|
65
|
+
user_id: userId,
|
|
66
|
+
name: template.name,
|
|
67
|
+
description: template.description,
|
|
68
|
+
intent: template.intent,
|
|
69
|
+
priority: template.priority,
|
|
70
|
+
condition: template.condition as any,
|
|
71
|
+
actions: template.actions as any,
|
|
72
|
+
instructions: template.instructions,
|
|
73
|
+
is_enabled: template.is_enabled_by_default,
|
|
74
|
+
pack: packId,
|
|
75
|
+
rule_template_id: template.id,
|
|
76
|
+
is_system_managed: true
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (ruleError) {
|
|
80
|
+
logger.error(`Failed to create rule '${template.id}'`, ruleError);
|
|
81
|
+
continue; // Continue with other rules even if one fails
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
rulesCreated++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Track pack installation
|
|
88
|
+
const { error: installError } = await this.supabase
|
|
89
|
+
.from('pack_installations')
|
|
90
|
+
.insert({
|
|
91
|
+
user_id: userId,
|
|
92
|
+
pack_id: packId,
|
|
93
|
+
source: 'manual'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (installError) {
|
|
97
|
+
logger.error(`Failed to track pack installation`, installError);
|
|
98
|
+
// Don't fail the whole operation if tracking fails
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
logger.info(`Successfully installed pack '${packId}' with ${rulesCreated} rules`);
|
|
102
|
+
return { success: true, rulesCreated };
|
|
103
|
+
|
|
104
|
+
} catch (error: any) {
|
|
105
|
+
logger.error(`Error installing pack '${packId}'`, error);
|
|
106
|
+
return { success: false, rulesCreated: 0, error: error.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Uninstall a rule pack for a user
|
|
112
|
+
* Disables all rules from the pack and marks it as uninstalled
|
|
113
|
+
*/
|
|
114
|
+
async uninstallPack(userId: string, packId: string): Promise<{ success: boolean; error?: string }> {
|
|
115
|
+
try {
|
|
116
|
+
logger.info(`Uninstalling pack '${packId}' for user ${userId}`);
|
|
117
|
+
|
|
118
|
+
// Disable all rules from this pack
|
|
119
|
+
const { error: disableError } = await this.supabase
|
|
120
|
+
.from('rules')
|
|
121
|
+
.update({ is_enabled: false })
|
|
122
|
+
.eq('user_id', userId)
|
|
123
|
+
.eq('pack', packId);
|
|
124
|
+
|
|
125
|
+
if (disableError) {
|
|
126
|
+
logger.error(`Failed to disable pack rules`, disableError);
|
|
127
|
+
return { success: false, error: disableError.message };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Mark pack as uninstalled
|
|
131
|
+
const { error: uninstallError } = await this.supabase
|
|
132
|
+
.from('pack_installations')
|
|
133
|
+
.update({ uninstalled_at: new Date().toISOString() })
|
|
134
|
+
.eq('user_id', userId)
|
|
135
|
+
.eq('pack_id', packId)
|
|
136
|
+
.is('uninstalled_at', null);
|
|
137
|
+
|
|
138
|
+
if (uninstallError) {
|
|
139
|
+
logger.error(`Failed to mark pack as uninstalled`, uninstallError);
|
|
140
|
+
return { success: false, error: uninstallError.message };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
logger.info(`Successfully uninstalled pack '${packId}'`);
|
|
144
|
+
return { success: true };
|
|
145
|
+
|
|
146
|
+
} catch (error: any) {
|
|
147
|
+
logger.error(`Error uninstalling pack '${packId}'`, error);
|
|
148
|
+
return { success: false, error: error.message };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get all installed packs for a user
|
|
154
|
+
*/
|
|
155
|
+
async getInstalledPacks(userId: string): Promise<PackInstallation[]> {
|
|
156
|
+
const { data, error } = await this.supabase
|
|
157
|
+
.from('pack_installations')
|
|
158
|
+
.select('*')
|
|
159
|
+
.eq('user_id', userId)
|
|
160
|
+
.is('uninstalled_at', null)
|
|
161
|
+
.order('installed_at', { ascending: false });
|
|
162
|
+
|
|
163
|
+
if (error) {
|
|
164
|
+
logger.error('Failed to fetch installed packs', error);
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return data || [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Update user role and install appropriate packs
|
|
173
|
+
* This is the main entry point for onboarding
|
|
174
|
+
*/
|
|
175
|
+
async updateUserRole(
|
|
176
|
+
userId: string,
|
|
177
|
+
role: UserRole,
|
|
178
|
+
source: 'onboarding' | 'manual' = 'onboarding'
|
|
179
|
+
): Promise<{ success: boolean; packsInstalled: string[]; error?: string }> {
|
|
180
|
+
try {
|
|
181
|
+
logger.info(`Updating user role to '${role}' for user ${userId}`);
|
|
182
|
+
|
|
183
|
+
// 1. Update user settings
|
|
184
|
+
const { error: settingsError } = await this.supabase
|
|
185
|
+
.from('user_settings')
|
|
186
|
+
.update({
|
|
187
|
+
user_role: role,
|
|
188
|
+
onboarding_completed: true
|
|
189
|
+
})
|
|
190
|
+
.eq('user_id', userId);
|
|
191
|
+
|
|
192
|
+
if (settingsError) {
|
|
193
|
+
logger.error('Failed to update user settings', settingsError);
|
|
194
|
+
return { success: false, packsInstalled: [], error: settingsError.message };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. Get packs for this role
|
|
198
|
+
const packs = getPacksForRole(role);
|
|
199
|
+
const packsInstalled: string[] = [];
|
|
200
|
+
|
|
201
|
+
// 3. Install each pack
|
|
202
|
+
for (const pack of packs) {
|
|
203
|
+
const result = await this.installPackWithSource(userId, pack.id, source);
|
|
204
|
+
if (result.success) {
|
|
205
|
+
packsInstalled.push(pack.id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
logger.info(`Successfully set up ${packsInstalled.length} packs for role '${role}'`);
|
|
210
|
+
return { success: true, packsInstalled };
|
|
211
|
+
|
|
212
|
+
} catch (error: any) {
|
|
213
|
+
logger.error(`Error updating user role`, error);
|
|
214
|
+
return { success: false, packsInstalled: [], error: error.message };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Install a pack with a specific source tracking
|
|
220
|
+
*/
|
|
221
|
+
private async installPackWithSource(
|
|
222
|
+
userId: string,
|
|
223
|
+
packId: string,
|
|
224
|
+
source: 'onboarding' | 'manual' | 'auto'
|
|
225
|
+
): Promise<{ success: boolean; rulesCreated: number; error?: string }> {
|
|
226
|
+
const pack = getPackById(packId);
|
|
227
|
+
if (!pack) {
|
|
228
|
+
return { success: false, rulesCreated: 0, error: `Pack '${packId}' not found` };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check if already installed
|
|
232
|
+
const { data: existing } = await this.supabase
|
|
233
|
+
.from('pack_installations')
|
|
234
|
+
.select('*')
|
|
235
|
+
.eq('user_id', userId)
|
|
236
|
+
.eq('pack_id', packId)
|
|
237
|
+
.is('uninstalled_at', null)
|
|
238
|
+
.single();
|
|
239
|
+
|
|
240
|
+
if (existing) {
|
|
241
|
+
return { success: true, rulesCreated: 0 }; // Already installed
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Create rules
|
|
245
|
+
let rulesCreated = 0;
|
|
246
|
+
for (const template of pack.rules) {
|
|
247
|
+
const { data: existingRule } = await this.supabase
|
|
248
|
+
.from('rules')
|
|
249
|
+
.select('id')
|
|
250
|
+
.eq('user_id', userId)
|
|
251
|
+
.eq('rule_template_id', template.id)
|
|
252
|
+
.single();
|
|
253
|
+
|
|
254
|
+
if (existingRule) continue;
|
|
255
|
+
|
|
256
|
+
const { error } = await this.supabase
|
|
257
|
+
.from('rules')
|
|
258
|
+
.insert({
|
|
259
|
+
user_id: userId,
|
|
260
|
+
name: template.name,
|
|
261
|
+
description: template.description,
|
|
262
|
+
intent: template.intent,
|
|
263
|
+
priority: template.priority,
|
|
264
|
+
condition: template.condition as any,
|
|
265
|
+
actions: template.actions as any,
|
|
266
|
+
instructions: template.instructions,
|
|
267
|
+
is_enabled: template.is_enabled_by_default,
|
|
268
|
+
pack: packId,
|
|
269
|
+
rule_template_id: template.id,
|
|
270
|
+
is_system_managed: true
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (!error) rulesCreated++;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Track installation
|
|
277
|
+
await this.supabase
|
|
278
|
+
.from('pack_installations')
|
|
279
|
+
.insert({
|
|
280
|
+
user_id: userId,
|
|
281
|
+
pack_id: packId,
|
|
282
|
+
source
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return { success: true, rulesCreated };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get pack metrics (how many users have it installed, etc.)
|
|
290
|
+
*/
|
|
291
|
+
async getPackMetrics(packId: string): Promise<{
|
|
292
|
+
totalInstallations: number;
|
|
293
|
+
activeInstallations: number;
|
|
294
|
+
uninstallations: number;
|
|
295
|
+
}> {
|
|
296
|
+
const { data: all } = await this.supabase
|
|
297
|
+
.from('pack_installations')
|
|
298
|
+
.select('*')
|
|
299
|
+
.eq('pack_id', packId);
|
|
300
|
+
|
|
301
|
+
const total = all?.length || 0;
|
|
302
|
+
const active = all?.filter(p => !p.uninstalled_at).length || 0;
|
|
303
|
+
const uninstalled = all?.filter(p => p.uninstalled_at).length || 0;
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
totalInstallations: total,
|
|
307
|
+
activeInstallations: active,
|
|
308
|
+
uninstallations: uninstalled
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Auto-install universal pack for new users
|
|
314
|
+
* Called during signup or first sync
|
|
315
|
+
*/
|
|
316
|
+
async ensureUniversalPack(userId: string): Promise<{ success: boolean; installed: boolean }> {
|
|
317
|
+
try {
|
|
318
|
+
// Check if user already has universal pack
|
|
319
|
+
const { data: existing } = await this.supabase
|
|
320
|
+
.from('pack_installations')
|
|
321
|
+
.select('*')
|
|
322
|
+
.eq('user_id', userId)
|
|
323
|
+
.eq('pack_id', 'universal')
|
|
324
|
+
.is('uninstalled_at', null)
|
|
325
|
+
.single();
|
|
326
|
+
|
|
327
|
+
if (existing) {
|
|
328
|
+
return { success: true, installed: false }; // Already has it
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Install universal pack
|
|
332
|
+
const result = await this.installPackWithSource(userId, 'universal', 'auto');
|
|
333
|
+
|
|
334
|
+
logger.info(`Auto-installed Universal Pack for new user ${userId}`);
|
|
335
|
+
return { success: result.success, installed: true };
|
|
336
|
+
|
|
337
|
+
} catch (error: any) {
|
|
338
|
+
logger.error('Failed to ensure universal pack', error);
|
|
339
|
+
return { success: false, installed: false };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get singleton instance (convenience)
|
|
346
|
+
*/
|
|
347
|
+
let defaultInstance: RulePackService | null = null;
|
|
348
|
+
|
|
349
|
+
export function getRulePackService(supabase: SupabaseClient): RulePackService {
|
|
350
|
+
if (!defaultInstance) {
|
|
351
|
+
defaultInstance = new RulePackService(supabase);
|
|
352
|
+
}
|
|
353
|
+
return defaultInstance;
|
|
354
|
+
}
|
|
@@ -441,6 +441,74 @@ export class GmailService {
|
|
|
441
441
|
logger.debug('Message starred', { messageId });
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Get or create a label by name (supports nested labels like "Finance/Receipts")
|
|
446
|
+
* Returns the label ID
|
|
447
|
+
*/
|
|
448
|
+
async getOrCreateLabel(account: EmailAccount, labelPath: string): Promise<string> {
|
|
449
|
+
const gmail = await this.getAuthenticatedClient(account);
|
|
450
|
+
|
|
451
|
+
// List existing labels
|
|
452
|
+
const { data: labelsData } = await gmail.users.labels.list({ userId: 'me' });
|
|
453
|
+
const existingLabels = labelsData.labels || [];
|
|
454
|
+
|
|
455
|
+
// Check if label already exists
|
|
456
|
+
const existingLabel = existingLabels.find(l => l.name === labelPath);
|
|
457
|
+
if (existingLabel?.id) {
|
|
458
|
+
logger.debug('Label already exists', { labelPath, labelId: existingLabel.id });
|
|
459
|
+
return existingLabel.id;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Create nested labels if needed (e.g., "Finance/Receipts" creates "Finance" then "Finance/Receipts")
|
|
463
|
+
const parts = labelPath.split('/');
|
|
464
|
+
let currentPath = '';
|
|
465
|
+
let parentId: string | undefined;
|
|
466
|
+
|
|
467
|
+
for (const part of parts) {
|
|
468
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
469
|
+
|
|
470
|
+
// Check if this level exists
|
|
471
|
+
const existing = existingLabels.find(l => l.name === currentPath);
|
|
472
|
+
if (existing?.id) {
|
|
473
|
+
parentId = existing.id;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Create this level
|
|
478
|
+
const { data: newLabel } = await gmail.users.labels.create({
|
|
479
|
+
userId: 'me',
|
|
480
|
+
requestBody: {
|
|
481
|
+
name: currentPath,
|
|
482
|
+
labelListVisibility: 'labelShow',
|
|
483
|
+
messageListVisibility: 'show'
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
if (!newLabel.id) {
|
|
488
|
+
throw new Error(`Failed to create label: ${currentPath}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
logger.info('Created label', { labelPath: currentPath, labelId: newLabel.id });
|
|
492
|
+
parentId = newLabel.id;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!parentId) {
|
|
496
|
+
throw new Error(`Failed to get or create label: ${labelPath}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return parentId;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Apply a label to a message by label name (creates label if needed)
|
|
504
|
+
* Supports nested labels like "Finance/Receipts"
|
|
505
|
+
*/
|
|
506
|
+
async applyLabelByName(account: EmailAccount, messageId: string, labelName: string): Promise<void> {
|
|
507
|
+
const labelId = await this.getOrCreateLabel(account, labelName);
|
|
508
|
+
await this.addLabel(account, messageId, [labelId]);
|
|
509
|
+
logger.debug('Applied label to message', { messageId, labelName, labelId });
|
|
510
|
+
}
|
|
511
|
+
|
|
444
512
|
private async fetchAttachment(supabase: SupabaseClient, path: string): Promise<Uint8Array> {
|
|
445
513
|
const { data, error } = await supabase.storage
|
|
446
514
|
.from('rule-attachments')
|