feat(sms): bring-your-own-provider — cafés use their own SMS account
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 5m16s

The platform no longer sells SMS. Each café saves its OWN Kavenegar API
key + sender line (new Cafes columns + migration) and campaigns are sent
and billed through that account.

Backend:
- GET/PUT /sms/settings (Manager/Owner; key echoed masked, verified
  against the provider before saving)
- campaign + balance use the café's credentials; SMS_NOT_CONFIGURED
  error when missing; plan-tier SMS gating removed everywhere
  (PlanLimitChecker, SmsMarketingService, billing status)
- platform Kavenegar config stays ONLY for login OTPs (env/DB)
- design-time DbContext factory so `dotnet ef migrations add` works
  without booting the host

Dashboard:
- SMS screen: provider-settings card, not-configured callout, campaign
  form disabled until configured; quota bar removed (usage stays as info)
- subscription screen + plan comparison no longer show SMS limits

Admin panel:
- Kavenegar/SMS section removed from integrations (request field now
  optional; stored OTP config untouched)
- SMS limit field removed from the plan editor
- nav label "درگاه و پیامک" → "درگاه پرداخت و AI"

fa/en/ar translations. 86 tests pass; all tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-12 09:23:50 +03:30
parent 615d5348de
commit 00649d0248
24 changed files with 3953 additions and 188 deletions
+1 -1
View File
@@ -1020,7 +1020,7 @@
"title": "مدیریت سامانه",
"dashboard": "داشبورد",
"plans": "اشتراک و قیمت",
"integrations": "درگاه و پیامک",
"integrations": "درگاه پرداخت و AI",
"notifications": "اعلان‌ها",
"settings": "تنظیمات اپ",
"features": "قابلیت‌ها",
@@ -143,7 +143,7 @@ const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
{ key: "maxMenuItems", label: "maxItems" },
{ key: "maxCustomers", label: "maxCustomers" },
{ key: "maxReportHistoryDays", label: "maxReportDays" },
{ key: "maxSmsPerMonth", label: "maxSms" },
// No SMS limit — marketing SMS is bring-your-own-provider per café.
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
];
@@ -701,11 +701,6 @@ export function AdminIntegrationsScreen() {
hasStoredClientSecret: prev?.hasStoredClientSecret ?? false,
...patch,
});
const [kavenegar, setKavenegar] = useState({
isEnabled: true,
apiKey: "",
otpTemplate: "verify",
});
const [openAi, setOpenAi] = useState({
isEnabled: false,
apiKey: "",
@@ -722,11 +717,6 @@ export function AdminIntegrationsScreen() {
if (!data) return;
setActiveGateway(data.activePaymentGateway);
setGateways(data.paymentGateways.map((g) => ({ ...g })));
setKavenegar({
isEnabled: data.kavenegar.isEnabled,
apiKey: data.kavenegar.apiKey ?? "",
otpTemplate: data.kavenegar.otpTemplate,
});
setOpenAi({
isEnabled: data.ai.openAi.isEnabled,
apiKey: data.ai.openAi.apiKey ?? "",
@@ -770,7 +760,7 @@ export function AdminIntegrationsScreen() {
}
: undefined,
})),
kavenegar,
// SMS is bring-your-own-provider per café — no platform SMS config here.
ai: { openAi, meshy },
}),
onSuccess: () => {
@@ -998,39 +988,6 @@ export function AdminIntegrationsScreen() {
))}
</section>
<section className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("kavenegarTitle")}
</p>
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={kavenegar.isEnabled}
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("apiKey")}
<Input
className="mt-1"
type="password"
placeholder={data?.kavenegar.hasStoredApiKey ? "••••••••" : ""}
value={kavenegar.apiKey}
onChange={(e) => setKavenegar((k) => ({ ...k, apiKey: e.target.value }))}
/>
</label>
<label className="block text-sm">
{t("otpTemplate")}
<Input
className="mt-1"
value={kavenegar.otpTemplate}
onChange={(e) => setKavenegar((k) => ({ ...k, otpTemplate: e.target.value }))}
/>
</label>
</Card>
</section>
<section className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("aiTitle")}