Skip to content

Commit 931aa2e

Browse files
rorarclaude
andcommitted
feat(settings): remove hard automation limit, add configurable performance warning
Removed: hardcoded MAX_AUTOMATIONS_PER_USER = 10 (was blocking creation) Added: soft performance warning when automation count >= threshold (default 10) Settings: - New AutomationSettings interface (performanceWarningEnabled, threshold) - New Automation section in Settings sidebar with toggle + threshold input - Default: warning ON, threshold 10 UX: - Warning banner on automations page when threshold exceeded - Warning toast after creating automation when threshold exceeded - Warning is informational only — never blocks creation i18n: 8 new keys in 4 locales Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9927c19 commit 931aa2e

10 files changed

Lines changed: 323 additions & 12 deletions

File tree

src/actions/automation.actions.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ import { APP_CONSTANTS } from "@/lib/constants";
2626
import { handleError } from "@/lib/utils";
2727
import { ActionResult } from "@/models/actionResult";
2828
import { validateConnectorParams } from "@/lib/connector/params-validator";
29-
30-
const MAX_AUTOMATIONS_PER_USER = 10;
29+
import { getAutomationSettingsForUser } from "@/actions/userSettings.actions";
3130

3231
/** Narrow Prisma string fields to domain enums for Automation (with resume included). */
3332
function toAutomationWithResume<T extends { jobBoard: string; status: string; pauseReason: string | null }>(
@@ -75,10 +74,21 @@ export async function getAutomationsList(
7574
prisma.automation.count({ where: { userId: user.id } }),
7675
]);
7776

77+
// Soft warning when automation count exceeds user's configured threshold
78+
let warning: string | undefined;
79+
const automationSettings = await getAutomationSettingsForUser(user.id);
80+
if (
81+
automationSettings.performanceWarningEnabled &&
82+
total >= automationSettings.performanceWarningThreshold
83+
) {
84+
warning = `performanceWarning:${total}`;
85+
}
86+
7887
return {
7988
success: true,
8089
data: automations.map(toAutomationWithResume) as AutomationWithResume[],
8190
total,
91+
message: warning,
8292
};
8393
} catch (error) {
8494
return handleError(error, "Failed to get automations list");
@@ -132,11 +142,6 @@ export async function createAutomation(
132142

133143
const validated = CreateAutomationSchema.parse(input);
134144

135-
const count = await prisma.automation.count({ where: { userId: user.id } });
136-
if (count >= MAX_AUTOMATIONS_PER_USER) {
137-
return { success: false, message: `Maximum of ${MAX_AUTOMATIONS_PER_USER} automations allowed per user` };
138-
}
139-
140145
const resume = await prisma.resume.findFirst({
141146
where: {
142147
id: validated.resumeId,
@@ -194,9 +199,21 @@ export async function createAutomation(
194199
},
195200
});
196201

202+
// Soft warning when automation count exceeds user's configured threshold
203+
let warning: string | undefined;
204+
const automationCount = await prisma.automation.count({ where: { userId: user.id } });
205+
const automationSettings = await getAutomationSettingsForUser(user.id);
206+
if (
207+
automationSettings.performanceWarningEnabled &&
208+
automationCount >= automationSettings.performanceWarningThreshold
209+
) {
210+
warning = `performanceWarning:${automationCount}`;
211+
}
212+
197213
return {
198214
success: true,
199215
data: toAutomationWithResume(automation) as AutomationWithResume,
216+
message: warning,
200217
};
201218
} catch (error) {
202219
return handleError(error, "Failed to create automation");

src/actions/userSettings.actions.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
UserSettingsData,
1010
defaultUserSettings,
1111
AiSettings,
12+
AutomationSettings,
1213
DisplaySettings,
1314
} from "@/models/userSettings.model";
1415

@@ -86,13 +87,19 @@ export const updateUserSettings = async (
8687
...currentSettings.display,
8788
...settings.display,
8889
},
90+
automation: {
91+
...defaultUserSettings.automation!,
92+
...currentSettings.automation,
93+
...settings.automation,
94+
},
8995
};
9096
} else {
9197
mergedSettings = {
9298
...defaultUserSettings,
9399
...settings,
94100
ai: { ...defaultUserSettings.ai, ...settings.ai },
95101
display: { ...defaultUserSettings.display, ...settings.display },
102+
automation: { ...defaultUserSettings.automation!, ...settings.automation },
96103
};
97104
}
98105

@@ -142,3 +149,28 @@ export const updateDisplaySettings = async (
142149
}
143150
return updateUserSettings({ display: displaySettings });
144151
};
152+
153+
export const updateAutomationSettings = async (
154+
automationSettings: AutomationSettings
155+
): Promise<ActionResult<UserSettings>> => {
156+
return updateUserSettings({ automation: automationSettings });
157+
};
158+
159+
/**
160+
* Fetch the resolved automation settings for a given userId.
161+
* Merges DB settings over defaults so callers always get a complete object.
162+
* Used internally by automation actions — NOT a server action export.
163+
*/
164+
export async function getAutomationSettingsForUser(
165+
userId: string
166+
): Promise<AutomationSettings> {
167+
const defaults = defaultUserSettings.automation!;
168+
try {
169+
const row = await prisma.userSettings.findUnique({ where: { userId } });
170+
if (!row) return defaults;
171+
const parsed: UserSettingsData = JSON.parse(row.settings);
172+
return { ...defaults, ...parsed.automation };
173+
} catch {
174+
return defaults;
175+
}
176+
}

src/app/dashboard/settings/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from "react";
44
import AiSettings from "@/components/settings/AiSettings";
55
import ApiKeySettings from "@/components/settings/ApiKeySettings";
6+
import AutomationSettings from "@/components/settings/AutomationSettings";
67
import DeveloperSettings from "@/components/settings/DeveloperSettings";
78
import DisplaySettings from "@/components/settings/DisplaySettings";
89
import ErrorLogSettings from "@/components/settings/ErrorLogSettings";
@@ -25,6 +26,7 @@ function Settings() {
2526
{activeSection === "ai-module" && <AiSettings />}
2627
{activeSection === "api-keys" && <ApiKeySettings />}
2728
{activeSection === "appearance" && <DisplaySettings />}
29+
{activeSection === "automation" && <AutomationSettings />}
2830
{activeSection === "developer" && <DeveloperSettings />}
2931
{activeSection === "error-log" && <ErrorLogSettings />}
3032
</div>

src/components/automations/AutomationContainer.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useCallback, useEffect, useState } from "react";
44
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
55
import { Button } from "@/components/ui/button";
6-
import { Plus, RefreshCw } from "lucide-react";
6+
import { AlertTriangle, Plus, RefreshCw } from "lucide-react";
77
import { toast } from "@/components/ui/use-toast";
88
import { useTranslations } from "@/i18n";
99
import { getAutomationsList } from "@/actions/automation.actions";
@@ -28,13 +28,23 @@ export function AutomationContainer({ resumes }: AutomationContainerProps) {
2828
const [wizardOpen, setWizardOpen] = useState(false);
2929
const [editAutomation, setEditAutomation] =
3030
useState<AutomationWithResume | null>(null);
31+
const [performanceWarning, setPerformanceWarning] = useState<string | null>(null);
3132

3233
const loadAutomations = useCallback(async () => {
3334
setLoading(true);
3435
const result = await getAutomationsList();
3536

3637
if (result.success && result.data) {
3738
setAutomations(result.data as any);
39+
// Check for performance warning from server
40+
if (result.message?.startsWith("performanceWarning:")) {
41+
const count = result.message.split(":")[1];
42+
setPerformanceWarning(
43+
t("automations.performanceWarningBanner").replace("{count}", count)
44+
);
45+
} else {
46+
setPerformanceWarning(null);
47+
}
3848
} else {
3949
toast({
4050
title: t("automations.validationError"),
@@ -84,6 +94,12 @@ export function AutomationContainer({ resumes }: AutomationContainerProps) {
8494
</Button>
8595
</div>
8696
</CardHeader>
97+
{performanceWarning && (
98+
<div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-900 dark:bg-yellow-950 dark:text-yellow-200">
99+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
100+
<span>{performanceWarning}</span>
101+
</div>
102+
)}
87103
<CardContent>
88104
{resumes.length === 0 ? (
89105
<div className="text-center py-8 text-muted-foreground">

src/components/automations/AutomationWizard.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,16 @@ export function AutomationWizard({
285285
? t("automations.automationUpdatedDesc")
286286
: t("automations.automationCreatedDesc"),
287287
});
288+
289+
// Show performance warning as a separate toast if returned by the server
290+
if (result.message?.startsWith("performanceWarning:")) {
291+
toast({
292+
title: t("automations.warning"),
293+
description: t("automations.performanceWarning"),
294+
variant: "default",
295+
});
296+
}
297+
288298
form.reset();
289299
setStep(0);
290300
onOpenChange(false);
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { Switch } from "../ui/switch";
5+
import { Label } from "../ui/label";
6+
import { Input } from "../ui/input";
7+
import { Button } from "../ui/button";
8+
import { toast } from "../ui/use-toast";
9+
import { Check, Loader2 } from "lucide-react";
10+
import { getUserSettings, updateAutomationSettings } from "@/actions/userSettings.actions";
11+
import { useTranslations } from "@/i18n";
12+
import type { AutomationSettings as AutomationSettingsType } from "@/models/userSettings.model";
13+
14+
const defaultAutomation: AutomationSettingsType = {
15+
performanceWarningEnabled: true,
16+
performanceWarningThreshold: 10,
17+
};
18+
19+
function AutomationSettings() {
20+
const { t } = useTranslations();
21+
const [isLoading, setIsLoading] = useState(true);
22+
const [settings, setSettings] = useState<AutomationSettingsType>(defaultAutomation);
23+
const [thresholdInput, setThresholdInput] = useState("10");
24+
25+
useEffect(() => {
26+
const fetchSettings = async () => {
27+
setIsLoading(true);
28+
try {
29+
const result = await getUserSettings();
30+
if (result.success && (result.data as any)?.settings?.automation) {
31+
const automation = (result.data as any).settings.automation;
32+
const merged = { ...defaultAutomation, ...automation };
33+
setSettings(merged);
34+
setThresholdInput(String(merged.performanceWarningThreshold));
35+
}
36+
} catch (error) {
37+
console.error("Error fetching automation settings:", error);
38+
} finally {
39+
setIsLoading(false);
40+
}
41+
};
42+
fetchSettings();
43+
}, []);
44+
45+
const handleToggle = async (update: Partial<AutomationSettingsType>) => {
46+
const newSettings: AutomationSettingsType = {
47+
...settings,
48+
...update,
49+
};
50+
setSettings(newSettings);
51+
52+
try {
53+
const result = await updateAutomationSettings(newSettings);
54+
if (result.success) {
55+
toast({
56+
variant: "success",
57+
title: t("settings.saved"),
58+
});
59+
} else {
60+
toast({
61+
variant: "destructive",
62+
title: t("settings.error"),
63+
description: t("settings.saveFailed"),
64+
});
65+
setSettings(settings);
66+
}
67+
} catch {
68+
toast({
69+
variant: "destructive",
70+
title: t("settings.error"),
71+
description: t("settings.saveFailed"),
72+
});
73+
setSettings(settings);
74+
}
75+
};
76+
77+
const handleThresholdSave = () => {
78+
const value = parseInt(thresholdInput, 10);
79+
if (isNaN(value) || value < 1) {
80+
setThresholdInput(String(settings.performanceWarningThreshold));
81+
return;
82+
}
83+
handleToggle({ performanceWarningThreshold: value });
84+
};
85+
86+
if (isLoading) {
87+
return (
88+
<div className="space-y-4">
89+
<div>
90+
<h3 className="text-lg font-medium">{t("settings.automationSettings")}</h3>
91+
<p className="text-sm text-muted-foreground">
92+
{t("settings.automationSettingsDesc")}
93+
</p>
94+
</div>
95+
<div className="flex items-center gap-2">
96+
<Loader2 className="h-4 w-4 animate-spin" />
97+
<span>{t("settings.loadingSettings")}</span>
98+
</div>
99+
</div>
100+
);
101+
}
102+
103+
return (
104+
<div className="space-y-4">
105+
<div>
106+
<h3 className="text-lg font-medium">{t("settings.automationSettings")}</h3>
107+
<p className="text-sm text-muted-foreground">
108+
{t("settings.automationSettingsDesc")}
109+
</p>
110+
</div>
111+
112+
<div className="space-y-4">
113+
{/* Performance warning toggle */}
114+
<div className="flex items-center justify-between rounded-lg border p-4">
115+
<div className="space-y-0.5">
116+
<Label htmlFor="performance-warning">
117+
{t("settings.automationPerformanceWarning")}
118+
</Label>
119+
<p className="text-sm text-muted-foreground">
120+
{t("settings.automationPerformanceWarningDesc")}
121+
</p>
122+
</div>
123+
<Switch
124+
id="performance-warning"
125+
checked={settings.performanceWarningEnabled}
126+
onCheckedChange={(checked) =>
127+
handleToggle({ performanceWarningEnabled: checked })
128+
}
129+
aria-label={t("settings.automationPerformanceWarning")}
130+
/>
131+
</div>
132+
133+
{/* Performance warning threshold */}
134+
<div className="rounded-lg border p-4 space-y-2">
135+
<div className="space-y-0.5">
136+
<Label htmlFor="performance-threshold">
137+
{t("settings.automationPerformanceThreshold")}
138+
</Label>
139+
<p className="text-sm text-muted-foreground">
140+
{t("settings.automationPerformanceThresholdDesc")}
141+
</p>
142+
</div>
143+
<div className="flex gap-2">
144+
<Input
145+
id="performance-threshold"
146+
type="number"
147+
min={1}
148+
className="w-24"
149+
value={thresholdInput}
150+
disabled={!settings.performanceWarningEnabled}
151+
onChange={(e) => setThresholdInput(e.target.value)}
152+
onKeyDown={(e) => {
153+
if (e.key === "Enter") {
154+
e.preventDefault();
155+
handleThresholdSave();
156+
}
157+
}}
158+
/>
159+
<Button
160+
variant="outline"
161+
size="icon"
162+
className="shrink-0"
163+
disabled={!settings.performanceWarningEnabled}
164+
onClick={handleThresholdSave}
165+
aria-label={t("common.save")}
166+
>
167+
<Check className="h-4 w-4" />
168+
</Button>
169+
</div>
170+
</div>
171+
</div>
172+
</div>
173+
);
174+
}
175+
176+
export default AutomationSettings;

0 commit comments

Comments
 (0)