diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/README.md b/.github/README.md similarity index 72% rename from README.md rename to .github/README.md index 9fe60e7..3fa5803 100644 --- a/README.md +++ b/.github/README.md @@ -1,6 +1,6 @@ -![ByteSend](./apps/web/public/nameplate.png) +![ByteSend](../apps/web/public/nameplate.png) -**Open-source email infrastructure that works.** REST API, SMTP relay, campaigns, contact management, and real-time webhooks - self-hosted or managed on [bytesend.cloud](https://bytesend.cloud). +**Open-source email infrastructure that works.** REST API, SMTP relay, campaigns, contact management, and real-time webhooks self-hosted or managed on [bytesend.cloud](https://bytesend.cloud). [Dashboard](https://bytesend.cloud) · [Docs](https://docs.bytesend.cloud) · [API Reference](https://docs.bytesend.cloud/api-reference/introduction) · [Discord](https://discord.gg/xqkqzVRC4S) @@ -8,17 +8,8 @@ ## Cloud -The fastest way to get started is the managed cloud at [bytesend.cloud](https://bytesend.cloud). No infrastructure to manage — just sign up, verify a domain, and start sending. +The fastest way to get started is the managed cloud at [bytesend.cloud](https://bytesend.cloud). No infrastructure to manage just sign up, verify a domain, and start sending. -| Plan | Price | Emails/month | Domains | Contacts | -|---|---|---|---|---| -| **Free** | CA$0 | 12,500 | 2 | 100 | -| **Hobby** | CA$5/mo | 25,000 | 4 | 200 | -| **Lite** | CA$10/mo | 50,000 | 6 | 300 | -| **Professional** | CA$30/mo | 150,000 | 100 | 1,000,000 | -| **Lifetime** | CA$60 once | Unlimited | 500 | 10,000,000 | - -Hobby and above include marketing campaigns and usage-based overage billing. Professional and Lifetime include advanced analytics and priority support. --- diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 100% rename from SECURITY.md rename to .github/SECURITY.md diff --git a/SUPPORT.md b/.github/SUPPORT.md similarity index 100% rename from SUPPORT.md rename to .github/SUPPORT.md diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5d03efb..17a8df6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -154,6 +154,9 @@ jobs: for APP_NAME in bytesend smtp-proxy; do for TAG in $TAGS; do + # Clean up any existing manifest (allows re-running without failures) + docker manifest rm bytesend/$APP_NAME:$TAG 2>/dev/null || true + docker manifest create \ bytesend/$APP_NAME:$TAG \ --amend bytesend/$APP_NAME-amd64:$TAG \ @@ -185,6 +188,9 @@ jobs: for APP_NAME in bytesend smtp-proxy; do for TAG in $TAGS; do + # Clean up any existing manifest (allows re-running without failures) + docker manifest rm ghcr.io/bytesend/$APP_NAME:$TAG 2>/dev/null || true + docker manifest create \ ghcr.io/bytesend/$APP_NAME:$TAG \ --amend ghcr.io/bytesend/$APP_NAME-amd64:$TAG \ diff --git a/.references/notification-integration.md b/.references/notification-integration.md new file mode 100644 index 0000000..06767ee --- /dev/null +++ b/.references/notification-integration.md @@ -0,0 +1,393 @@ +# Notification Provider Integration Guide + +## Overview + +The notification provider system allows your team to receive real-time alerts via Discord, Slack, Microsoft Teams, Telegram, or custom webhooks. This guide shows how to integrate notifications into your existing services. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Event Sources │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Webhook │ │ Campaign │ │ Domain │ │ +│ │ Service │ │ Service │ │ Service │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ │ +│ └──────────────────┼───────────────────┘ │ +│ ▼ │ +│ NotificationEmitter │ +│ (High-level notification API) │ +│ │ │ +│ ┌──────────────────┼──────────────────┐ │ +│ ▼ ▼ ▼ │ +│ NotificationProviderService (routes to providers) │ +│ │ │ +│ ┌────┴──────┬──────────┬──────────┬──────────┐ │ +│ ▼ ▼ ▼ ▼ ▼ │ +│ Discord Slack Teams Telegram Custom │ +│ Provider Provider Provider Provider Webhook │ +│ │ │ │ │ │ │ +│ └───────────┴─────────┴─────────┴──────────┘ │ +│ ▼ │ +│ External Services │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Integration Points + +### 1. Webhook Service (Email Events) + +**File**: `apps/web/src/server/service/webhook-service.ts` + +Add notifications when emails are delivered, bounced, or opened: + +```typescript +import { NotificationEmitter } from "./notification-emitter"; + +// In your WebhookService.emit() method or webhook processor: + +if (eventType === "email.delivered") { + NotificationEmitter.emitEmailEvent(teamId, "delivered", { + emailId: payload.data.emailId, + to: payload.data.to, + subject: payload.data.subject, + }).catch(err => logger.error("Notification failed", err)); +} + +if (eventType === "email.bounced") { + NotificationEmitter.emitEmailEvent(teamId, "bounced", { + emailId: payload.data.emailId, + to: payload.data.to, + subject: payload.data.subject, + }).catch(err => logger.error("Notification failed", err)); +} + +if (eventType === "email.complained") { + NotificationEmitter.emitEmailEvent(teamId, "complained", { + emailId: payload.data.emailId, + to: payload.data.to, + subject: payload.data.subject, + }).catch(err => logger.error("Notification failed", err)); +} + +if (eventType === "email.opened") { + NotificationEmitter.emitEmailEvent(teamId, "opened", { + emailId: payload.data.emailId, + to: payload.data.to, + subject: payload.data.subject, + }).catch(err => logger.error("Notification failed", err)); +} + +if (eventType === "email.clicked") { + NotificationEmitter.emitEmailEvent(teamId, "clicked", { + emailId: payload.data.emailId, + to: payload.data.to, + subject: payload.data.subject, + }).catch(err => logger.error("Notification failed", err)); +} +``` + +### 2. Campaign Service + +**File**: `apps/web/src/server/service/` (wherever campaigns are updated) + +Add notifications when campaigns start/complete: + +```typescript +import { NotificationEmitter } from "~/server/service/notification-emitter"; + +// When campaign starts: +await NotificationEmitter.emitCampaignEvent(teamId, "started", { + campaignId: campaign.id, + campaignName: campaign.name, + totalContacts: campaign.total, +}); + +// When campaign completes: +await NotificationEmitter.emitCampaignEvent(teamId, "completed", { + campaignId: campaign.id, + campaignName: campaign.name, + totalContacts: campaign.total, + sent: campaign.sent, +}); +``` + +### 3. Domain Service + +**File**: `apps/web/src/server/service/` (wherever domains are verified) + +Add notifications when domains are verified: + +```typescript +import { NotificationEmitter } from "~/server/service/notification-emitter"; + +// When domain verification succeeds: +await NotificationEmitter.emitDomainEvent(teamId, { + domainName: domain.name, + status: "verified", +}); + +// When domain verification fails: +await NotificationEmitter.emitDomainEvent(teamId, { + domainName: domain.name, + status: "failed", + error: "DKIM verification failed", +}); +``` + +### 4. Contact Service + +**File**: `apps/web/src/server/service/` (wherever contacts are created/deleted) + +Add notifications for contact events: + +```typescript +import { NotificationEmitter } from "~/server/service/notification-emitter"; + +// When contacts are created: +await NotificationEmitter.emitContactEvent(teamId, "created", { + contactEmail: contact.email, + contactBookName: contactBook.name, + count: 1, +}); + +// When contacts are deleted: +await NotificationEmitter.emitContactEvent(teamId, "deleted", { + contactEmail: contact.email, + contactBookName: contactBook.name, +}); +``` + +### 5. Error Handling + +Use notifications for critical errors: + +```typescript +import { NotificationEmitter } from "~/server/service/notification-emitter"; + +try { + // Some operation +} catch (error) { + await NotificationEmitter.emitErrorAlert(teamId, { + title: "Email sending failed", + message: "Failed to send campaign emails", + errorCode: "SEND_FAILED", + context: { + campaignId: campaign.id, + error: error instanceof Error ? error.message : "Unknown error", + }, + }).catch(err => logger.error("Failed to send error notification", err)); + + throw error; +} +``` + +## Frontend Usage + +### Accessing the Notification Settings Page + +Users can access notifications at: `/settings/notifications` + +This page provides: +- List of configured providers +- Create/edit/delete providers +- Test message sending +- Statistics on sent/failed notifications +- Event type filtering +- Documentation for each provider + +### API Endpoints + +All endpoints are available via tRPC at `api.notificationProvider.*`: + +```typescript +// List providers +const providers = await api.notificationProvider.list.useQuery(); + +// Create provider +api.notificationProvider.create.useMutation({ + type: "DISCORD", + name: "Team Alerts", + config: { webhookUrl: "https://..." }, + eventTypes: ["EMAIL_DELIVERED", "ERROR_ALERT"], +}); + +// Update provider +api.notificationProvider.update.useMutation({ + id: "provider_id", + isActive: false, +}); + +// Delete provider +api.notificationProvider.delete.useMutation({ id: "provider_id" }); + +// Test provider +api.notificationProvider.test.useMutation({ id: "provider_id" }); + +// Get logs +api.notificationProvider.getLogs.useQuery({ + providerId: "provider_id", + limit: 50, +}); + +// Get stats +const stats = api.notificationProvider.getStats.useQuery(); +``` + +## Provider Configuration + +### Discord + +```json +{ + "type": "DISCORD", + "config": { + "webhookUrl": "https://discord.com/api/webhooks/...", + "mentionRole": "123456789", + "threadId": "987654321" + } +} +``` + +### Slack + +```json +{ + "type": "SLACK", + "config": { + "webhookUrl": "https://hooks.slack.com/services/...", + "channelId": "C123456", + "botToken": "xoxb-..." + } +} +``` + +### Microsoft Teams + +```json +{ + "type": "MICROSOFT_TEAMS", + "config": { + "webhookUrl": "https://outlook.webhook.office.com/webhookb2/...", + "adaptiveCard": true + } +} +``` + +### Telegram + +```json +{ + "type": "TELEGRAM", + "config": { + "botToken": "123456:ABC...", + "chatId": "987654321" + } +} +``` + +### Custom Webhook + +```json +{ + "type": "CUSTOM_WEBHOOK", + "config": { + "url": "https://api.example.com/webhooks/notifications", + "secret": "your-secret-key", + "headers": { + "Authorization": "Bearer token", + "X-Custom-Header": "value" + } + } +} +``` + +## Notification Format + +All notifications follow this structure: + +```json +{ + "title": "Notification title", + "description": "Optional detailed description", + "color": "#0EA5E9", + "fields": [ + { + "name": "Field name", + "value": "Field value", + "inline": true + } + ], + "timestamp": true, + "data": { + "additional": "context data" + } +} +``` + +Each provider formats this according to its platform's capabilities: +- **Discord**: Uses embeds with colors and field formatting +- **Slack**: Uses block kit with formatted text +- **Teams**: Uses adaptive cards +- **Telegram**: Uses markdown formatting +- **Custom**: Sends raw JSON with optional HMAC-SHA256 signature + +## Event Types + +Available events to filter notifications: + +- `EMAIL_SENT` - Email queued for sending +- `EMAIL_DELIVERED` - Email successfully delivered +- `EMAIL_BOUNCED` - Email hard bounced +- `EMAIL_COMPLAINED` - Recipient marked as spam +- `EMAIL_OPENED` - Email opened by recipient +- `EMAIL_CLICKED` - Link clicked in email +- `CONTACT_CREATED` - Contact added to list +- `CONTACT_DELETED` - Contact removed +- `DOMAIN_VERIFIED` - Domain verification completed +- `CAMPAIGN_STARTED` - Campaign began sending +- `CAMPAIGN_COMPLETED` - Campaign finished +- `ERROR_ALERT` - Critical error occurred + +## Error Handling & Retry Logic + +- Failed notifications are logged to `NotificationLog` table +- Providers track consecutive failures +- Auto-disable after X consecutive failures +- Manual test messages help troubleshoot issues +- All notification sends are async (fire-and-forget) + +## Security Considerations + +- Webhook URLs and tokens are stored encrypted in the database +- Sensitive config fields are marked as `type="password"` in forms +- Custom webhooks support HMAC-SHA256 signatures for verification +- Sensitive data in notifications should be limited to IDs, not PII + +## Best Practices + +1. **Avoid notification fatigue** - Use event filtering to receive only important events +2. **Test before deploying** - Use the "Test" button on each provider +3. **Monitor logs** - Check notification logs for delivery issues +4. **Graceful failures** - Wrap notification sends in try-catch to prevent service interruption +5. **Rate limiting** - Consider limiting high-frequency events (e.g., EMAIL_OPENED) + +## Monitoring & Debugging + +Check the notification dashboard at `/settings/notifications` to: +- View provider status and failure counts +- See recent notification logs +- Verify event type filtering is working +- Test provider connectivity + +## Future Enhancements + +Potential additions: +- Notification templates/customization +- Scheduled digests (hourly/daily summaries) +- Rate limiting per provider +- Notification deduplication +- Batch notifications +- Webhook retry exponential backoff +- Provider-specific formatting options diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f6611..f7550b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,16 +15,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### Authentication +- **GitHub OAuth support** — added GitHub as a sign-in provider alongside Discord (`GITHUB_ID` / `GITHUB_SECRET`), enabling GitHub auth for cloud and self-hosted deployments + +#### Notification Providers +- **Multi-provider notifications** — teams can now configure external alerting providers for operational events (email, campaign, domain, and error notifications) +- **Supported providers** — Discord, Slack, Microsoft Teams, Telegram, and Custom Webhook are now supported with provider-specific configuration +- **Notification provider schema** — added `NotificationProvider` model to store provider type, config, status, and team linkage +- **Notification log schema** — added `NotificationLog` model to store per-dispatch delivery outcomes and failure details +- **Notification provider API** — added `notificationProvider` tRPC router with `list`, `getById`, `create`, `update`, `delete`, `test`, `getLogs`, and `getStats` +- **Notification dispatcher services** — added provider dispatch and event emission services (`notification-provider-service.ts` and `notification-emitter.ts`) +- **Notifications settings page** — added `/settings/notifications` UI for provider management, testing, logs, and usage guidance +- **Notification integration reference** — added `.references/notification-integration.md` + +#### Admin / Billing Operations +- **Admin plan assignment flow** — added `adminAssignPlan` to let cloud admins assign plans to teams either as complimentary grants or Stripe checkout-link driven assignments +- **Admin team billing controls** — admin team settings now supports `dailyEmailLimit = -1` for unlimited daily sending + +#### Billing / Plan Source-of-Truth +- **Perks derived from shared plan constants** — `apps/web/src/lib/constants/payments.ts` now generates plan perks from `@bytesend/lib` PLANS rather than static duplicated data +- **Billing plan cards from shared plans** — `/settings/billing` plan options now derive from the shared PLANS map to avoid UI/config drift + #### CI / Automation - **Issue summary workflow** — added `.github/workflows/issue-summary.yml` to automatically summarize newly opened issues - **Stale cleanup workflow** — added `.github/workflows/stale-cleanup.yml` to clean up inactive issues and pull requests - **CodeQL workflow** — introduced `.github/workflows/codeql.yml` and enabled `develop` branch triggers +- **JavaScript SDK release workflow** — added `.github/workflows/npm-release.yml` to build and publish the `bytesend-js` package from `packages/sdk` on pushes to `main` and manual dispatch #### Community / Governance -- **Repository security policy** — added root `SECURITY.md` with supported versions, private reporting process, and response expectations -- **Code of Conduct** — added root `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1) -- **Contributing guide** — added root `CONTRIBUTING.md` with development workflow, PR expectations, and testing checklist -- **Support guide** — added root `SUPPORT.md` with support channels and security-report routing +- **Repository security policy** — added `.github/SECURITY.md` with supported versions, private reporting process, and response expectations +- **Code of Conduct** — added `.github/CODE_OF_CONDUCT.md` (Contributor Covenant v2.1) +- **Contributing guide** — added `.github/CONTRIBUTING.md` with development workflow, PR expectations, and testing checklist +- **Support guide** — added `.github/SUPPORT.md` with support channels and security-report routing #### GitHub Templates - **PR template** — added `.github/PULL_REQUEST_TEMPLATE.md` to standardize change summaries, testing notes, and release-impact checks @@ -33,6 +55,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +#### Plans & Pricing +- **BASIC plan updated** — aligned plan limits/pricing model by setting BASIC to CA$20/mo with 100,000 monthly emails, 30 members, and 12 domains +- **LIFETIME plan updated** — aligned lifetime limits to current plan progression at CA$199 one-time with 500,000 monthly emails, 100 members, and 30 domains + +#### Settings UX +- **Settings navigation restructured** — removed Team inner General/Members subtabs and promoted them to top-level Settings navigation +- **General tab behavior** — `/settings` now serves as the General overview (team profile and core team settings) +- **Members tab split-out** — members management moved to dedicated `/settings/members` tab alongside billing/usage-related settings +- **Usage resource breakdowns** — usage view now includes explicit domain, webhook, and member usage breakdowns with limit context + #### SMTP Server - **SMTP server vendored into monorepo** — `apps/smtp-server` is now tracked directly in this repository (no gitlink/submodule-style entry), simplifying versioning and release consistency - **Authentication compatibility fallback** — SMTP auth now supports the API-driven custom team username flow while retaining a legacy fallback username candidate for older client configurations @@ -54,11 +86,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Label action token update** — updated token reference in `.github/workflows/label.yml` - **Website test workflow tuning** — adjusted website test workflow behavior - **Docker publish workflow update** — updated `.github/workflows/docker-publish.yml` +- **Docker manifest recreation safety** — docker publish now removes existing manifests before create, preventing rerun failures on previously published tags - **Website tests pnpm version alignment** — removed hardcoded pnpm version from `.github/workflows/website-test.yml` so CI uses the repository `packageManager` version (`pnpm@9.0.0`) - **Docker publish tag strategy hardening** — `.github/workflows/docker-publish.yml` now publishes ref-aware tags (`latest`, `develop`, version tag, and commit SHA) with matching multi-arch manifests - **Manual Docker publish branch support** — wired `workflow_dispatch` branch input into checkout and tag resolution so manual runs build/publish the selected branch - **Labeler rules refresh** — updated `.github/labeler.yml` to align automated PR labeling with the current repository structure -- **JavaScript SDK release workflow** — added `.github/workflows/npm-release.yml` to build and publish the `bytesend-js` package from `packages/sdk` on pushes to `main` and manual dispatch ### Fixed @@ -76,6 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Usage unit test expectation alignment** — `apps/web/src/lib/usage.unit.test.ts` now derives expected costs from exported usage constants instead of stale hardcoded values - **Workspace SDK resolution in Vitest** — `apps/web/vitest.config.ts` now aliases `bytesend-js` to `packages/sdk/index.ts` during tests so unit suites do not depend on prebuilt SDK `dist` artifacts - **Contact-service unit test isolation** — `apps/web/src/server/service/contact-service.unit.test.ts` now mocks `LimitService.checkContactsLimit` to avoid transitive `TeamService` cache dependencies and prevent brittle failures +- **Campaign security test alignment** — `apps/web/src/server/api/routers/campaign-security.trpc.test.ts` updated for current plan access expectations #### SMTP Server - **SMTP Dockerfile context compatibility** — `apps/smtp-server/Dockerfile` no longer expects `pnpm-lock.yaml` in app-only build contexts and now uses an app-local install path that works with the `apps/smtp-server` Docker build context diff --git a/apps/web/prisma/migrations/20260509160937_add_notification_providers/migration.sql b/apps/web/prisma/migrations/20260509160937_add_notification_providers/migration.sql new file mode 100644 index 0000000..64960b1 --- /dev/null +++ b/apps/web/prisma/migrations/20260509160937_add_notification_providers/migration.sql @@ -0,0 +1,64 @@ +-- CreateEnum +CREATE TYPE "NotificationProviderType" AS ENUM ('DISCORD', 'SLACK', 'MICROSOFT_TEAMS', 'TELEGRAM', 'CUSTOM_WEBHOOK'); + +-- CreateEnum +CREATE TYPE "NotificationEventType" AS ENUM ('EMAIL_SENT', 'EMAIL_DELIVERED', 'EMAIL_BOUNCED', 'EMAIL_COMPLAINED', 'EMAIL_OPENED', 'EMAIL_CLICKED', 'CONTACT_CREATED', 'CONTACT_DELETED', 'DOMAIN_VERIFIED', 'CAMPAIGN_STARTED', 'CAMPAIGN_COMPLETED', 'ERROR_ALERT'); + +-- AlterTable +ALTER TABLE "ContactBook" ALTER COLUMN "emoji" SET DEFAULT '�'; + +-- CreateTable +CREATE TABLE "NotificationProvider" ( + "id" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "type" "NotificationProviderType" NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "config" JSONB NOT NULL, + "eventTypes" "NotificationEventType"[] DEFAULT ARRAY[]::"NotificationEventType"[], + "isActive" BOOLEAN NOT NULL DEFAULT true, + "testMessageSentAt" TIMESTAMP(3), + "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, + "lastFailureAt" TIMESTAMP(3), + "lastSuccessAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NotificationLog" ( + "id" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "providerId" TEXT NOT NULL, + "eventType" "NotificationEventType" NOT NULL, + "payload" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "attempt" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "responseStatus" INTEGER, + "responseTimeMs" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "NotificationProvider_teamId_isActive_idx" ON "NotificationProvider"("teamId", "isActive"); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationProvider_teamId_type_name_key" ON "NotificationProvider"("teamId", "type", "name"); + +-- CreateIndex +CREATE INDEX "NotificationLog_teamId_providerId_status_idx" ON "NotificationLog"("teamId", "providerId", "status"); + +-- CreateIndex +CREATE INDEX "NotificationLog_createdAt_idx" ON "NotificationLog"("createdAt" DESC); + +-- AddForeignKey +ALTER TABLE "NotificationProvider" ADD CONSTRAINT "NotificationProvider_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/migration_lock.toml b/apps/web/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/web/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index b32ab23..7715c49 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -134,6 +134,8 @@ model Team { suppressionList SuppressionList[] webhookEndpoints Webhook[] webhookCalls WebhookCall[] + notificationProviders NotificationProvider[] + notificationLogs NotificationLog[] } model TeamInvite { @@ -313,7 +315,7 @@ model ContactBook { doubleOptInContent String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - emoji String @default("📙") + emoji String @default("�") team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) contacts Contact[] @@ -520,3 +522,66 @@ model WebhookCall { @@index([teamId, webhookId, status]) @@index([createdAt(sort: Desc)]) } + +enum NotificationProviderType { + DISCORD + SLACK + MICROSOFT_TEAMS + TELEGRAM + CUSTOM_WEBHOOK +} + +enum NotificationEventType { + EMAIL_SENT + EMAIL_DELIVERED + EMAIL_BOUNCED + EMAIL_COMPLAINED + EMAIL_OPENED + EMAIL_CLICKED + CONTACT_CREATED + CONTACT_DELETED + DOMAIN_VERIFIED + CAMPAIGN_STARTED + CAMPAIGN_COMPLETED + ERROR_ALERT +} + +model NotificationProvider { + id String @id @default(cuid()) + teamId Int + type NotificationProviderType + name String + description String? + config Json // Stores provider-specific config (webhookUrl, channelId, token, etc.) + eventTypes NotificationEventType[] @default([]) + isActive Boolean @default(true) + testMessageSentAt DateTime? + consecutiveFailures Int @default(0) + lastFailureAt DateTime? + lastSuccessAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([teamId, type, name]) + @@index([teamId, isActive]) +} + +model NotificationLog { + id String @id @default(cuid()) + teamId Int + providerId String + eventType NotificationEventType + payload Json + status String @default("PENDING") // PENDING, SENT, FAILED + attempt Int @default(0) + lastError String? + responseStatus Int? + responseTimeMs Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@index([teamId, providerId, status]) + @@index([createdAt(sort: Desc)]) +} diff --git a/apps/web/src/app/(dashboard)/admin/teams/page.tsx b/apps/web/src/app/(dashboard)/admin/teams/page.tsx index 78b7c82..8aba621 100644 --- a/apps/web/src/app/(dashboard)/admin/teams/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/teams/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { z } from "zod"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronLeft, ChevronRight, Link2, Gift } from "lucide-react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { Button } from "@bytesend/ui/src/button"; @@ -85,13 +85,21 @@ function AdminStatusPill({ } const updateSchema = z.object({ - apiRateLimit: z.coerce.number().int().min(1).max(10_000), - dailyEmailLimit: z.coerce.number().int().min(0).max(10_000_000), + // -1 = no rate limit + apiRateLimit: z.coerce.number().int().min(-1).max(10_000), + // -1 = unlimited override, 0 = use plan default, positive = custom cap + dailyEmailLimit: z.coerce.number().int().min(-1).max(10_000_000), isBlocked: z.boolean(), plan: z.enum(["FREE", "HOBBY", "LITE", "BASIC", "LIFETIME"]), }); +const assignSchema = z.object({ + plan: z.enum(["FREE", "HOBBY", "LITE", "BASIC", "LIFETIME"]), + method: z.enum(["complimentary", "checkout_link"]), +}); + type UpdateInput = z.infer; +type AssignInput = z.infer; export default function AdminTeamsPage() { const [team, setTeam] = useState(null); @@ -99,6 +107,8 @@ export default function AdminTeamsPage() { const [teamsPage, setTeamsPage] = useState(1); const [teamsQuery, setTeamsQuery] = useState(""); const [teamsQueryInput, setTeamsQueryInput] = useState(""); + const [dailyLimitUnlimited, setDailyLimitUnlimited] = useState(false); + const [generatedCheckoutUrl, setGeneratedCheckoutUrl] = useState(null); const searchForm = useForm({ resolver: zodResolver(searchSchema), @@ -115,14 +125,25 @@ export default function AdminTeamsPage() { }, }); + const assignForm = useForm({ + resolver: zodResolver(assignSchema), + defaultValues: { + plan: "LITE", + method: "checkout_link", + }, + }); + useEffect(() => { if (team) { + const isUnlimited = team.dailyEmailLimit === -1; + setDailyLimitUnlimited(isUnlimited); updateForm.reset({ apiRateLimit: team.apiRateLimit, - dailyEmailLimit: team.dailyEmailLimit, + dailyEmailLimit: isUnlimited ? -1 : team.dailyEmailLimit, isBlocked: team.isBlocked, plan: team.plan, }); + setGeneratedCheckoutUrl(null); } }, [team, updateForm]); @@ -152,9 +173,11 @@ export default function AdminTeamsPage() { const updateTeam = api.admin.updateTeamSettings.useMutation({ onSuccess: (updated) => { setTeam(updated); + const isUnlimited = updated.dailyEmailLimit === -1; + setDailyLimitUnlimited(isUnlimited); updateForm.reset({ apiRateLimit: updated.apiRateLimit, - dailyEmailLimit: updated.dailyEmailLimit, + dailyEmailLimit: isUnlimited ? -1 : updated.dailyEmailLimit, isBlocked: updated.isBlocked, plan: updated.plan, }); @@ -165,6 +188,21 @@ export default function AdminTeamsPage() { }, }); + const assignPlan = api.admin.adminAssignPlan.useMutation({ + onSuccess: (result) => { + if (result.method === "complimentary" && result.team) { + setTeam(result.team); + toast.success(`Plan assigned as complimentary`); + } else if (result.method === "checkout_link" && result.url) { + setGeneratedCheckoutUrl(result.url); + toast.success("Checkout link generated"); + } + }, + onError: (error) => { + toast.error(error.message ?? "Failed to assign plan"); + }, + }); + const onSearchSubmit = (values: SearchInput) => { setTeam(null); setHasSearched(false); @@ -176,6 +214,12 @@ export default function AdminTeamsPage() { updateTeam.mutate({ teamId: team.id, ...values }); }; + const onAssignSubmit = (values: AssignInput) => { + if (!team) return; + setGeneratedCheckoutUrl(null); + assignPlan.mutate({ teamId: team.id, ...values }); + }; + const listTeams = api.admin.listTeams.useQuery( { page: teamsPage, pageSize: 20, query: teamsQuery || undefined }, { placeholderData: (prev) => prev }, @@ -330,17 +374,35 @@ export default function AdminTeamsPage() { Daily email limit - - field.onChange(Number(event.target.value)) - } - disabled={updateTeam.isPending} - /> +
+
+ { + setDailyLimitUnlimited(checked); + field.onChange(checked ? -1 : 10_000); + }} + disabled={updateTeam.isPending} + /> + + {dailyLimitUnlimited ? "Unlimited (no daily cap)" : "Custom daily cap"} + +
+ {!dailyLimitUnlimited && ( + + field.onChange(Number(event.target.value)) + } + disabled={updateTeam.isPending} + placeholder="e.g. 10000 — 0 uses plan default" + /> + )} +
@@ -410,6 +472,93 @@ export default function AdminTeamsPage() { + + {/* Plan Assignment */} +
+
+

Assign Plan

+

+ Assign a plan complimentarily (no Stripe charge) or generate a checkout link for the team to pay via Stripe. +

+
+
+ + ( + + Plan + + + + + + )} + /> + ( + + Method + + + + + + )} + /> + + + + {generatedCheckoutUrl && ( +
+

Checkout link — share with the team:

+
+ + {generatedCheckoutUrl} + + +
+
+ )} +
) : null} diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 6d885a6..8f764ea 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -10,44 +10,24 @@ import { useTeam } from "~/providers/team-context"; import { api } from "~/trpc/react"; import { PlanDetails } from "~/components/payments/PlanDetails"; import { UpgradeButton } from "~/components/payments/UpgradeButton"; +import { PLANS, getAllPlans } from "@bytesend/lib"; +import { PLAN_PERKS } from "~/lib/constants/payments"; -type PaidPlan = "HOBBY" | "LITE"; - -const PLAN_OPTIONS: { - plan: PaidPlan; - name: string; - price: string; - perks: string[]; - highlight?: boolean; -}[] = [ - { - plan: "HOBBY", - name: "Hobby", - price: "CA$5 / mo", - perks: [ - "25,000 emails / month", - "12,500 emails / day", - "4 domains + $1/extra", - "10 team members", - "Marketing CA$0.05/ea (overage)", - ], - }, - { - plan: "LITE", - name: "Lite", - price: "CA$10 / mo", - perks: [ - "50,000 emails / month", - "25,000 emails / day", - "6 domains + $1/extra", - "15 team members", - "Marketing CA$0.02/ea (overage)", - "Priority support", - ], - highlight: true, - }, +type UpgradePlan = "HOBBY" | "LITE" | "BASIC"; + +// Plans shown as upgrade options when on FREE plan (exclude FREE and LIFETIME) +const UPGRADE_PLANS: { plan: UpgradePlan; highlight?: boolean }[] = [ + { plan: "HOBBY" }, + { plan: "LITE", highlight: true }, + { plan: "BASIC" }, ]; +function formatPlanPrice(plan: (typeof PLANS)[keyof typeof PLANS]): string { + if (plan.oneTimePrice) return `CA$${plan.oneTimePrice / 100} one-time`; + if (plan.monthlyPrice === 0) return "Free"; + return `CA$${plan.monthlyPrice / 100} / mo`; +} + export default function SettingsPage() { const { currentTeam, currentIsAdmin } = useTeam(); const manageSessionUrl = api.billing.getManageSessionUrl.useMutation(); @@ -125,34 +105,38 @@ export default function SettingsPage() { {currentTeam?.plan === "FREE" && (

Upgrade Your Plan

-
- {PLAN_OPTIONS.map(({ plan, name, price, perks, highlight }) => ( -
-
- {name} - - {price} - +
+ {UPGRADE_PLANS.map(({ plan, highlight }) => { + const planData = PLANS[plan]; + const perks = PLAN_PERKS[plan] ?? []; + return ( +
+
+ {planData.displayName} + + {formatPlanPrice(planData)} + +
+
    + {perks.map((perk, i) => ( +
  • + + {perk} +
  • + ))} +
+
-
    - {perks.map((perk, i) => ( -
  • - - {perk} -
  • - ))} -
- -
- ))} + ); + })}
)} diff --git a/apps/web/src/app/(dashboard)/settings/layout.tsx b/apps/web/src/app/(dashboard)/settings/layout.tsx index 2f7b734..953f3c2 100644 --- a/apps/web/src/app/(dashboard)/settings/layout.tsx +++ b/apps/web/src/app/(dashboard)/settings/layout.tsx @@ -22,15 +22,17 @@ export default function ApiKeysPage({

+ General {isCloud() ? ( - Usage + Usage ) : null} {currentIsAdmin && isCloud() ? ( Billing ) : null} - Team + Members + Notifications
{children}
diff --git a/apps/web/src/app/(dashboard)/settings/members/page.tsx b/apps/web/src/app/(dashboard)/settings/members/page.tsx new file mode 100644 index 0000000..a11a215 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/members/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import InviteTeamMember from "../team/invite-team-member"; +import TeamMembersList from "../team/team-members-list"; +import { useTeam } from "~/providers/team-context"; + +export default function MembersPage() { + const { currentIsAdmin } = useTeam(); + + return ( +
+ {currentIsAdmin && ( +
+ +
+ )} + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/notifications/page.tsx b/apps/web/src/app/(dashboard)/settings/notifications/page.tsx new file mode 100644 index 0000000..32777cf --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/notifications/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import React from "react"; +import { NotificationProviderManager } from "~/components/notifications/notification-provider-manager"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@bytesend/ui/src/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@bytesend/ui/src/tabs"; +import { Badge } from "@bytesend/ui/src/badge"; +import { InfoIcon } from "lucide-react"; +import { api } from "~/trpc/react"; + +export default function NotificationSettingsPage() { + const { data: stats } = api.notificationProvider.getStats.useQuery(); + + return ( +
+ {/* Header */} +
+

Notifications

+

+ Configure notification channels to stay informed about important events +

+
+ + {/* Statistics Cards */} + {stats && ( +
+ + + Active Providers + + +
{stats.activeProviders}
+

+ of {stats.totalProviders} configured +

+
+
+ + + + Messages Sent + + +
{stats.logStats.sent}
+

Recent messages

+
+
+ + + + Failed + + +
{stats.logStats.failed}
+

+ {stats.failingProviders?.length || 0} provider(s) failing +

+
+
+ + + + By Type + + +
+ {Object.entries(stats.providersByType || {}).map(([type, count]) => ( +
+ {type} + {count} +
+ ))} +
+
+
+
+ )} + + {/* Info Alert */} +
+ + + Notification providers will receive real-time alerts for events like email delivery, bounces, + complaints, and more. Configure providers below and test them to ensure they're working correctly. + +
+ + {/* Main Content */} + + + Providers + Help & Documentation + + + + + + + + + + Setting Up Each Provider + + + {/* Discord */} +
+

+ Discord +

+
    +
  1. Go to your Discord server and open Server Settings
  2. +
  3. Navigate to Integrations → Webhooks
  4. +
  5. Click "New Webhook" and select a channel
  6. +
  7. Copy the Webhook URL and paste it in the configuration
  8. +
  9. Optionally add a role ID to mention on alerts
  10. +
+
+ + {/* Slack */} +
+

+ Slack +

+
    +
  1. Go to your Slack workspace and create a new app
  2. +
  3. Enable Incoming Webhooks in the app features
  4. +
  5. Create a new webhook for your desired channel
  6. +
  7. Copy the Webhook URL and use it in the configuration
  8. +
  9. For better functionality, also provide a Bot Token
  10. +
+
+ + {/* Microsoft Teams */} +
+

+ Microsoft Teams +

+
    +
  1. Open the Teams channel where you want notifications
  2. +
  3. Click the three dots (...) next to the channel name
  4. +
  5. Select "Connectors" and search for "Incoming Webhook"
  6. +
  7. Configure the webhook and copy the URL
  8. +
  9. Paste the URL in the configuration
  10. +
+
+ + {/* Telegram */} +
+

+ Telegram +

+
    +
  1. Create a new bot by messaging @BotFather on Telegram
  2. +
  3. Copy the bot token provided
  4. +
  5. Get your chat ID using @userinfobot or a Telegram API client
  6. +
  7. Enter both values in the configuration
  8. +
  9. Make sure to message the bot first to establish a conversation
  10. +
+
+ + {/* Custom Webhook */} +
+

+ Custom Webhook +

+

+ For any custom integration or third-party service: +

+
    +
  1. Provide your webhook URL that accepts POST requests
  2. +
  3. Optionally set custom headers for authentication
  4. +
  5. Optionally provide a secret to sign requests with HMAC-SHA256
  6. +
  7. The webhook will receive JSON notification data
  8. +
+
+
+
+ + {/* Notification Format */} + + + Notification Format + + +

+ Each notification will include the following information: +

+
+{`{
+  "title": "Event notification title",
+  "description": "Detailed description of the event",
+  "color": "#0EA5E9",
+  "fields": [
+    {
+      "name": "Field Name",
+      "value": "Field Value",
+      "inline": true
+    }
+  ],
+  "timestamp": true,
+  "data": { ... additional event data ... }
+}`}
+              
+
+
+ + {/* Event Types Reference */} + + + Available Event Types + + +

+ You can filter notifications by event type: +

+
+ {[ + "EMAIL_SENT", + "EMAIL_DELIVERED", + "EMAIL_BOUNCED", + "EMAIL_COMPLAINED", + "EMAIL_OPENED", + "EMAIL_CLICKED", + "CONTACT_CREATED", + "CONTACT_DELETED", + "DOMAIN_VERIFIED", + "CAMPAIGN_STARTED", + "CAMPAIGN_COMPLETED", + "ERROR_ALERT", + ].map((event) => ( + + {event} + + ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/page.tsx b/apps/web/src/app/(dashboard)/settings/page.tsx index eb0cd1d..45ce476 100644 --- a/apps/web/src/app/(dashboard)/settings/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/page.tsx @@ -1,27 +1,8 @@ "use client"; -import { isCloud } from "~/utils/common"; -import UsagePage from "./usage/usage"; -import InviteTeamMember from "./team/invite-team-member"; -import TeamMembersList from "./team/team-members-list"; +import TeamGeneralSettings from "./team/team-general-settings"; export default function SettingsPage() { - if (!isCloud()) { - return ( -
-
-
- -
- -
-
- ); - } - - return ( -
- -
- ); + return ; } + diff --git a/apps/web/src/app/(dashboard)/settings/team/page.tsx b/apps/web/src/app/(dashboard)/settings/team/page.tsx index 17efcc3..93b3015 100644 --- a/apps/web/src/app/(dashboard)/settings/team/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/team/page.tsx @@ -1,48 +1,8 @@ "use client"; -import { useState } from "react"; -import InviteTeamMember from "./invite-team-member"; -import TeamMembersList from "./team-members-list"; -import TeamGeneralSettings from "./team-general-settings"; -import { useTeam } from "~/providers/team-context"; - -type Tab = "general" | "members"; +import { redirect } from "next/navigation"; export default function TeamsPage() { - const { currentIsAdmin } = useTeam(); - const [tab, setTab] = useState("general"); - - return ( -
- {/* Sub-tabs */} -
- {(["general", "members"] as Tab[]).map((t) => ( - - ))} -
- - {tab === "general" && } - - {tab === "members" && ( - <> - {currentIsAdmin && ( -
- -
- )} - - - )} -
- ); + redirect("/settings"); } + diff --git a/apps/web/src/app/(dashboard)/settings/usage/page.tsx b/apps/web/src/app/(dashboard)/settings/usage/page.tsx new file mode 100644 index 0000000..750b02e --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/usage/page.tsx @@ -0,0 +1,5 @@ +import UsagePage from "./usage"; + +export default function Page() { + return ; +} diff --git a/apps/web/src/app/(dashboard)/settings/usage/usage.tsx b/apps/web/src/app/(dashboard)/settings/usage/usage.tsx index 4fc33fa..d00ddff 100644 --- a/apps/web/src/app/(dashboard)/settings/usage/usage.tsx +++ b/apps/web/src/app/(dashboard)/settings/usage/usage.tsx @@ -17,6 +17,7 @@ import { PlanDetails } from "~/components/payments/PlanDetails"; import { useUpgradeModalStore } from "~/store/upgradeModalStore"; import { Progress } from "@bytesend/ui/src/progress"; import { PLANS } from "@bytesend/lib"; +import { LimitReason } from "~/lib/constants/plans"; const UNLIMITED_PLANS = new Set(["BASIC", "LIFETIME"]); @@ -210,6 +211,90 @@ function PaidPlanUsage({ ); } +/* ────────── Resource limits ────────── */ + +function ResourceLimitRow({ + label, + currentCount, + limit, +}: { + label: string; + currentCount: number; + limit: number; +}) { + const unlimited = limit === -1; + const pct = unlimited ? 0 : Math.min((currentCount / limit) * 100, 100); + const nearLimit = !unlimited && pct >= 80; + + return ( +
+
+ {label} + + {currentCount.toLocaleString()}{" "} + {unlimited ? "/ ∞" : `/ ${limit.toLocaleString()}`} + +
+ {!unlimited && ( + div]:bg-amber-500" : ""}`} + /> + )} + {unlimited && ( +

No limit on this plan

+ )} +
+ ); +} + +function ResourceLimits() { + const { data: domainLimit, isLoading: dl } = api.limits.get.useQuery({ type: LimitReason.DOMAIN }); + const { data: memberLimit, isLoading: ml } = api.limits.get.useQuery({ type: LimitReason.TEAM_MEMBER }); + const { data: webhookLimit, isLoading: wl } = api.limits.get.useQuery({ type: LimitReason.WEBHOOK }); + + const isLoading = dl || ml || wl; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + Resource limits + + + {domainLimit && ( + + )} + {memberLimit && ( + + )} + {webhookLimit && ( + + )} + + + ); +} + /* ────────── Main page ────────── */ export default function UsagePage() { @@ -249,6 +334,9 @@ export default function UsagePage() { )} + {/* Resource limits */} + + {/* Current plan section */} {currentTeam?.plan && ( diff --git a/apps/web/src/components/notifications/notification-provider-manager.tsx b/apps/web/src/components/notifications/notification-provider-manager.tsx new file mode 100644 index 0000000..ff995e5 --- /dev/null +++ b/apps/web/src/components/notifications/notification-provider-manager.tsx @@ -0,0 +1,420 @@ +import React, { useState } from "react"; +import { api } from "~/trpc/react"; +import { Button } from "@bytesend/ui/src/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@bytesend/ui/src/dialog"; +import { Input } from "@bytesend/ui/src/input"; +import { Label } from "@bytesend/ui/src/label"; +import { Textarea } from "@bytesend/ui/src/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bytesend/ui/src/select"; +import { Badge } from "@bytesend/ui/src/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@bytesend/ui/src/card"; +import { AlertCircle, CheckCircle, Trash2, Edit2, Send, Eye, EyeOff } from "lucide-react"; +import { ProviderConfigForm, ProviderTypeBadge } from "./provider-config-form"; +import { toast } from "@bytesend/ui/src/toaster"; + +const PROVIDER_TYPES = [ + { value: "DISCORD", label: "Discord" }, + { value: "SLACK", label: "Slack" }, + { value: "MICROSOFT_TEAMS", label: "Microsoft Teams" }, + { value: "TELEGRAM", label: "Telegram" }, + { value: "CUSTOM_WEBHOOK", label: "Custom Webhook" }, +]; + +const EVENT_TYPES = [ + { value: "EMAIL_SENT", label: "Email Sent" }, + { value: "EMAIL_DELIVERED", label: "Email Delivered" }, + { value: "EMAIL_BOUNCED", label: "Email Bounced" }, + { value: "EMAIL_COMPLAINED", label: "Email Complained" }, + { value: "EMAIL_OPENED", label: "Email Opened" }, + { value: "EMAIL_CLICKED", label: "Email Clicked" }, + { value: "CONTACT_CREATED", label: "Contact Created" }, + { value: "CONTACT_DELETED", label: "Contact Deleted" }, + { value: "DOMAIN_VERIFIED", label: "Domain Verified" }, + { value: "CAMPAIGN_STARTED", label: "Campaign Started" }, + { value: "CAMPAIGN_COMPLETED", label: "Campaign Completed" }, + { value: "ERROR_ALERT", label: "Error Alert" }, +]; + +export function NotificationProviderManager() { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formData, setFormData] = useState({ + type: "DISCORD", + name: "", + description: "", + config: {}, + eventTypes: [] as string[], + }); + + const { data: providers, isLoading } = api.notificationProvider.list.useQuery(); + const { mutate: createProvider, isPending: isCreating } = api.notificationProvider.create.useMutation({ + onSuccess: () => { + toast.success("Provider created successfully"); + resetForm(); + setIsOpen(false); + utils.notificationProvider.list.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Failed to create provider"); + }, + }); + + const { mutate: updateProvider, isPending: isUpdating } = api.notificationProvider.update.useMutation({ + onSuccess: () => { + toast.success("Provider updated successfully"); + resetForm(); + setIsOpen(false); + utils.notificationProvider.list.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Failed to update provider"); + }, + }); + + const { mutate: deleteProvider, isPending: isDeleting } = api.notificationProvider.delete.useMutation({ + onSuccess: () => { + toast.success("Provider deleted successfully"); + utils.notificationProvider.list.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete provider"); + }, + }); + + const { mutate: testProvider, isPending: isTesting } = api.notificationProvider.test.useMutation({ + onSuccess: () => { + toast.success("Test message sent successfully"); + }, + onError: (error) => { + toast.error(error.message || "Failed to send test message"); + }, + }); + + const resetForm = () => { + setFormData({ + type: "DISCORD", + name: "", + description: "", + config: {}, + eventTypes: [], + }); + setEditingId(null); + }; + + const handleSubmit = () => { + if (!formData.name.trim()) { + toast.error("Provider name is required"); + return; + } + + if (editingId) { + updateProvider({ + id: editingId, + name: formData.name, + description: formData.description, + config: formData.config, + eventTypes: formData.eventTypes.length > 0 ? (formData.eventTypes as any) : undefined, + }); + } else { + createProvider({ + type: formData.type as any, + name: formData.name, + description: formData.description, + config: formData.config, + eventTypes: formData.eventTypes.length > 0 ? (formData.eventTypes as any) : undefined, + }); + } + }; + + const handleEdit = (provider: any) => { + setFormData({ + type: provider.type, + name: provider.name, + description: provider.description || "", + config: provider.config || {}, + eventTypes: provider.eventTypes || [], + }); + setEditingId(provider.id); + setIsOpen(true); + }; + + const toggleEventType = (eventType: string) => { + setFormData((prev) => ({ + ...prev, + eventTypes: prev.eventTypes.includes(eventType) + ? prev.eventTypes.filter((t) => t !== eventType) + : [...prev.eventTypes, eventType], + })); + }; + + if (isLoading) { + return
Loading providers...
; + } + + return ( +
+
+
+

Notification Providers

+

+ Manage notification channels for your team events +

+
+ +
+ + {/* Providers Grid */} +
+ {providers?.map((provider) => ( + + +
+
+ {provider.name} + + {provider.description || "No description"} + +
+ +
+
+ + +
+ {/* Status */} +
+ {provider.isActive ? ( + <> + + Active + + ) : ( + <> + + Inactive + + )} +
+ + {/* Event Types */} + {provider.eventTypes.length > 0 && ( +
+

+ Events: +

+
+ {provider.eventTypes.map((event) => ( + + {EVENT_TYPES.find((e) => e.value === event)?.label || event} + + ))} +
+
+ )} + + {/* Failure Status */} + {provider.consecutiveFailures > 0 && ( +
+ ⚠️ {provider.consecutiveFailures} consecutive failures +
+ )} + + {/* Last Activity */} + {provider.lastSuccessAt && ( +
+ Last success: {new Date(provider.lastSuccessAt).toLocaleDateString()} +
+ )} +
+ + {/* Actions */} +
+ + + +
+
+
+ ))} +
+ + {providers?.length === 0 && ( + +

+ No notification providers yet. Create one to get started. +

+
+ )} + + {/* Add/Edit Provider Dialog */} + + + + + {editingId ? "Edit Provider" : "Add Notification Provider"} + + + Configure a notification provider to receive event alerts + + + +
+ {/* Provider Type */} + {!editingId && ( +
+ + +
+ )} + + {/* Provider Name */} +
+ + + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + /> +
+ + {/* Description */} +
+ +