Merge branch 'andrewhooker2/secrt-1077-add-email-service-settings-page' into ntindle/secrt-1077-add-email-service

This commit is contained in:
Nicholas Tindle
2025-02-14 01:18:50 -06:00
committed by GitHub
15 changed files with 725 additions and 643 deletions

View File

@@ -217,6 +217,14 @@ class NotificationTypeOverride:
}[self.notification_type]
class NotificationPreferenceDTO(BaseModel):
email: EmailStr = Field(..., description="User's email address")
preferences: dict[NotificationType, bool] = Field(
..., description="Which notifications the user wants"
)
daily_limit: int = Field(..., description="Max emails per day")
class NotificationPreference(BaseModel):
user_id: str
email: EmailStr

View File

@@ -1,4 +1,3 @@
from datetime import timedelta
import logging
from abc import ABC, abstractmethod
from enum import Enum
@@ -263,7 +262,6 @@ class AsyncRabbitMQ(RabbitMQBase):
self,
routing_key: str,
message: str,
expiration: Optional[timedelta] = None,
exchange: Optional[Exchange] = None,
persistent: bool = True,
) -> None:
@@ -286,7 +284,6 @@ class AsyncRabbitMQ(RabbitMQBase):
if persistent
else aio_pika.DeliveryMode.NOT_PERSISTENT
),
expiration=expiration if expiration else None,
),
routing_key=routing_key,
)

View File

@@ -7,10 +7,11 @@ from fastapi import HTTPException
from prisma import Json
from prisma.enums import NotificationType
from prisma.models import User
from prisma.types import UserUpdateInput
from backend.data.db import prisma
from backend.data.model import UserIntegrations, UserMetadata, UserMetadataRaw
from backend.data.notifications import NotificationPreference
from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO
from backend.server.v2.store.exceptions import DatabaseError
from backend.util.encryption import JSONCryptor
@@ -57,6 +58,15 @@ async def get_user_email_by_id(user_id: str) -> str:
raise DatabaseError(f"Failed to get user email for user {user_id}: {e}") from e
async def update_user_email(user_id: str, email: str):
try:
await prisma.user.update(where={"id": user_id}, data={"email": email})
except Exception as e:
raise DatabaseError(
f"Failed to update user email for user {user_id}: {e}"
) from e
async def create_default_user() -> Optional[User]:
user = await prisma.user.find_unique(where={"id": DEFAULT_USER_ID})
if not user:
@@ -213,3 +223,64 @@ async def get_user_notification_preference(user_id: str) -> NotificationPreferen
raise DatabaseError(
f"Failed to upsert user notification preference for user {user_id}: {e}"
) from e
async def update_user_notification_preference(
user_id: str, data: NotificationPreferenceDTO
) -> NotificationPreference:
try:
update_data: UserUpdateInput = {}
if data.email:
update_data["email"] = data.email
if data.preferences.get(NotificationType.AGENT_RUN):
update_data["notifyOnAgentRun"] = True
if data.preferences.get(NotificationType.ZERO_BALANCE):
update_data["notifyOnZeroBalance"] = True
if data.preferences.get(NotificationType.LOW_BALANCE):
update_data["notifyOnLowBalance"] = True
if data.preferences.get(NotificationType.BLOCK_EXECUTION_FAILED):
update_data["notifyOnBlockExecutionFailed"] = True
if data.preferences.get(NotificationType.CONTINUOUS_AGENT_ERROR):
update_data["notifyOnContinuousAgentError"] = True
if data.preferences.get(NotificationType.DAILY_SUMMARY):
update_data["notifyOnDailySummary"] = True
if data.preferences.get(NotificationType.WEEKLY_SUMMARY):
update_data["notifyOnWeeklySummary"] = True
if data.preferences.get(NotificationType.MONTHLY_SUMMARY):
update_data["notifyOnMonthlySummary"] = True
if data.daily_limit:
update_data["maxEmailsPerDay"] = data.daily_limit
user = await User.prisma().update(
where={"id": user_id},
data=update_data,
)
if not user:
raise ValueError(f"User not found with ID: {user_id}")
preferences: dict[NotificationType, bool] = {
NotificationType.AGENT_RUN: user.notifyOnAgentRun or True,
NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or True,
NotificationType.LOW_BALANCE: user.notifyOnLowBalance or True,
NotificationType.BLOCK_EXECUTION_FAILED: user.notifyOnBlockExecutionFailed
or True,
NotificationType.CONTINUOUS_AGENT_ERROR: user.notifyOnContinuousAgentError
or True,
NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or True,
NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or True,
NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or True,
}
notification_preference = NotificationPreference(
user_id=user.id,
email=user.email,
preferences=preferences,
daily_limit=user.maxEmailsPerDay or 3,
# TODO with other changes later, for now we just will email them
emails_sent_today=0,
last_reset_date=datetime.now(),
)
return NotificationPreference.model_validate(notification_preference)
except Exception as e:
raise DatabaseError(
f"Failed to update user notification preference for user {user_id}: {e}"
) from e

View File

@@ -1,12 +1,3 @@
import json
import logging
import time
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Optional
from aio_pika.exceptions import QueueEmpty
from autogpt_libs.utils.cache import thread_cached
from prisma.models import UserNotificationBatch
import logging
import time
from typing import TYPE_CHECKING
@@ -19,13 +10,11 @@ from backend.data.notifications import (
NotificationEventDTO,
NotificationEventModel,
NotificationResult,
get_batch_delay,
get_data_type,
)
from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig
from backend.executor.database import DatabaseManager
from backend.notifications.email import EmailSender
from backend.notifications.summary import SummaryManager
from backend.util.service import AppService, expose, get_service_client
from backend.util.settings import Settings
@@ -40,8 +29,6 @@ def create_notification_config() -> RabbitMQConfig:
"""Create RabbitMQ configuration for notifications"""
notification_exchange = Exchange(name="notifications", type=ExchangeType.TOPIC)
# batch_exchange = Exchange(name="batching", type=ExchangeType.TOPIC)
summary_exchange = Exchange(name="summaries", type=ExchangeType.TOPIC)
dead_letter_exchange = Exchange(name="dead_letter", type=ExchangeType.DIRECT)
@@ -67,25 +54,6 @@ def create_notification_config() -> RabbitMQConfig:
"x-dead-letter-routing-key": "failed.backoff",
},
),
# Batch queues for aggregation
# Queue(
# name="hourly_batch", exchange=batch_exchange, routing_key="batch.hourly.#"
# ),
# Queue(name="daily_batch", exchange=batch_exchange, routing_key="batch.daily.#"),
# Queue(
# name="batch_rechecks_delay",
# exchange=delay_exchange,
# routing_key="batch.*.recheck",
# arguments={
# "x-dead-letter-exchange": batch_exchange.name,
# "x-dead-letter-routing-key": "batch.recheck",
# },
# ),
# Queue(
# name="batch_rechecks",
# exchange=batch_exchange,
# routing_key="batch.recheck",
# ),
# Summary queues
Queue(
name="daily_summary_trigger",
@@ -146,10 +114,6 @@ class NotificationManager(AppService):
return f"notification.immediate.{event.type.value}"
elif event.strategy == BatchingStrategy.BACKOFF:
return f"notification.backoff.{event.type.value}"
# elif event.strategy == BatchingStrategy.HOURLY:
# return f"batch.hourly.{event.type.value}"
# else: # DAILY
# return f"batch.daily.{event.type.value}"
return f"notification.{event.type.value}"
@expose
@@ -166,14 +130,6 @@ class NotificationManager(AppService):
logger.info(f"Recieved Request to queue {message=}")
# Get the appropriate exchange based on strategy
exchange = None
# if parsed_event.strategy in [
# BatchingStrategy.HOURLY,
# BatchingStrategy.DAILY,
# ]:
# exchange = "batching"
# else:
exchange = "notifications"
# Publish to RabbitMQ
@@ -196,104 +152,6 @@ class NotificationManager(AppService):
logger.error(f"Error queueing notification: {e}")
return NotificationResult(success=False, message=str(e))
async def _schedule_next_summary(self, summary_type: str, user_id: str):
"""Schedule the next summary generation using RabbitMQ delayed messages"""
routing_key = f"summary.{summary_type}"
message = json.dumps(
{
"user_id": user_id,
"summary_type": summary_type,
"scheduled_at": datetime.now().isoformat(),
}
)
await self.rabbit.publish_message(
routing_key=routing_key,
message=message,
exchange=next(
ex for ex in self.rabbit_config.exchanges if ex.name == "summaries"
),
)
@expose
async def process_summaries(
self, summary_type: Optional[str] = None, user_id: Optional[str] = None
):
"""
Process summaries for specified type and user, or all if not specified.
This is exposed for manual triggering but normally runs on schedule.
"""
now = datetime.now()
db = get_db_client()
summary_configs = {
"daily": (1, "days"),
"weekly": (7, "days"),
"monthly": (1, "months"),
}
# If summary_type specified, only process that type
if summary_type:
summary_configs = {
k: v for k, v in summary_configs.items() if k == summary_type
}
for summary_type, period_info in summary_configs.items():
amount, unit = period_info
start_time = self._get_period_start(now, amount, unit)
# Calculate end time
if unit == "months":
if start_time.month == 12:
end_time = datetime(start_time.year + 1, 1, 1)
else:
end_time = datetime(start_time.year, start_time.month + 1, 1)
else:
end_time = start_time + timedelta(**{unit: amount})
# Get users to process
if user_id:
# users = [db.get_user(user_id)]
users = []
else:
users = db.get_active_user_ids_in_timerange(
start_time.isoformat(), end_time.isoformat()
)
for user_id in users:
await self.summary_manager.generate_summary(
summary_type, user_id, start_time, end_time, self
)
# Schedule next summary if this wasn't manually triggered
if not user_id and not summary_type:
await self._schedule_next_summary(summary_type, user_id)
async def _process_summary_trigger(self, message: str):
"""Process a summary trigger message"""
try:
data = json.loads(message)
await self.process_summaries(
summary_type=data["summary_type"], user_id=data["user_id"]
)
except Exception as e:
logger.error(f"Error processing summary trigger: {e}")
def _get_period_start(self, now: datetime, amount: int, unit: str) -> datetime:
"""Get the start time for a summary period"""
if unit == "days":
if amount == 1: # Daily summary
return now.replace(
hour=0, minute=0, second=0, microsecond=0
) - timedelta(days=1)
else: # Weekly summary
return now - timedelta(days=now.weekday() + 7)
else: # Monthly summary
if now.month == 1:
return datetime(now.year - 1, 12, 1)
else:
return datetime(now.year, now.month - 1, 1)
async def _process_immediate(self, message: str) -> bool:
"""Process a single notification immediately, returning whether to put into the failed queue"""
try:
@@ -322,75 +180,6 @@ class NotificationManager(AppService):
logger.error(f"Error processing notification: {e}")
return False
# def should_send(self, batch: UserNotificationBatch) -> bool:
# """Determine if a batch should be sent"""
# if not batch.notifications:
# return False
# # if any notifications are older than the batch delay, send them
# if any(
# notification.created_at < datetime.now() - get_batch_delay(batch.type)
# for notification in batch.notifications
# if isinstance(notification, NotificationEventModel)
# ):
# logger.info(f"Sending batch of {len(batch.notifications)} notifications")
# return True
# return False
# async def _process_batch_message(self, message: str) -> bool:
# """Process a batch notification & return status from processing"""
# try:
# logger.info(f"Processing batch message: {message}")
# event = NotificationEventDTO.model_validate_json(message)
# logger.info(f"Event: {event}")
# parsed_event = NotificationEventModel[
# get_data_type(event.type)
# ].model_validate_json(message)
# logger.info(f"Processing batch ingestion of {parsed_event}")
# # Implementation of batch notification sending would go here
# # Add to database
# db = get_db_client()
# logger.info(f"Processing batch ingestion of {parsed_event}")
# logger.info(f"type of event: {type(parsed_event)}")
# batch = db.create_or_add_to_user_notification_batch(
# parsed_event.user_id, parsed_event.type, parsed_event.model_dump_json()
# )
# batch = UserNotificationBatch.model_validate(batch)
# if not batch.notifications:
# logger.info(
# f"No notifications to send for batch of {parsed_event.user_id}"
# )
# return True
# if self.should_send(batch):
# logger.info(
# f"Processing batch of {len(batch.notifications)} notifications"
# )
# db.empty_user_notification_batch(parsed_event.user_id, batch.type)
# # self.send_email_with_template(event.user_id, event.type, event.data)
# else:
# logger.info(
# f"Holding on to batch for {parsed_event.user_id} type {batch.type.lower()}"
# )
# logger.info(f"batch: {batch}")
# logger.info(f"delay: {get_batch_delay(batch.type)}")
# delay = get_batch_delay(batch.type)
# await self.rabbit.publish_message(
# routing_key=f"batch.{batch.type.lower()}.recheck",
# message=json.dumps(
# {"user_id": parsed_event.user_id, "type": batch.type}
# ),
# exchange=next(
# ex for ex in self.rabbit_config.exchanges if ex.name == "delay"
# ),
# expiration=delay,
# )
# return True
# except Exception as e:
# logger.error(f"Error processing batch: {e}")
# return False
def run_service(self):
logger.info(f"[{self.service_name}] Started notification service")
@@ -401,27 +190,6 @@ class NotificationManager(AppService):
channel.get_queue("immediate_notifications")
)
backoff_queue = self.run_and_wait(channel.get_queue("backoff_notifications"))
# hourly_queue = self.run_and_wait(channel.get_queue("hourly_batch"))
# daily_queue = self.run_and_wait(channel.get_queue("daily_batch"))
# recheck_queue = self.run_and_wait(channel.get_queue("batch_rechecks"))
# Set up summary queues
summary_queues = []
for summary_type in ["daily", "weekly", "monthly"]:
queue = self.run_and_wait(
channel.get_queue(f"{summary_type}_summary_trigger")
)
summary_queues.append(queue)
# Initial summary scheduling
for user_id in get_db_client().get_active_users_ids():
for summary_type in ["daily", "weekly", "monthly"]:
self.run_and_wait(self._schedule_next_summary(summary_type, user_id))
while self.running:
try:
# Process immediate notifications
@@ -439,87 +207,6 @@ class NotificationManager(AppService):
except QueueEmpty:
logger.debug("Immediate queue empty")
# Process backoff notifications similarly
try:
message = self.run_and_wait(backoff_queue.get())
if message:
# success = self.run_and_wait(
# self._process_backoff(message.body.decode())
# )
success = True
if success:
self.run_and_wait(message.ack())
else:
# If failed, will go to DLQ with delay
self.run_and_wait(message.reject(requeue=True))
except QueueEmpty:
logger.debug("Backoff queue empty")
# # Add to plan db/process batch and delay or send
# for queue in [
# hourly_queue,
# daily_queue,
# ]:
# try:
# message = self.run_and_wait(queue.get(no_ack=False))
# if message:
# success = self.run_and_wait(
# self._process_batch_message(message.body.decode())
# )
# if success:
# self.run_and_wait(message.ack())
# else:
# self.run_and_wait(message.reject(requeue=True))
# except QueueEmpty:
# logger.debug(f"Queue empty: {queue}")
# # Process batch rechecks
# try:
# message = self.run_and_wait(recheck_queue.get())
# if message:
# logger.info(f"Processing recheck message: {message}")
# data = json.loads(message.body.decode())
# db = get_db_client()
# batch = db.get_user_notification_batch(
# data["user_id"], data["type"]
# )
# if batch and self.should_send(
# UserNotificationBatch.model_validate(batch)
# ):
# # Send and empty the batch
# db.empty_user_notification_batch(
# data["user_id"], data["type"]
# )
# if batch.notifications:
# self.email_sender.send_templated(
# batch.type,
# data["user_id"],
# [
# NotificationEventModel[
# get_data_type(notification.type)
# ].model_validate(notification)
# for notification in batch.notifications
# ],
# )
# self.run_and_wait(message.ack())
# except QueueEmpty:
# logger.debug("Recheck queue empty")
# Process summary triggers
for queue in summary_queues:
try:
message = self.run_and_wait(queue.get())
if message:
self.run_and_wait(
self._process_summary_trigger(message.body.decode())
)
self.run_and_wait(message.ack())
except QueueEmpty:
logger.debug(f"Queue empty: {queue}")
time.sleep(0.1)
except QueueEmpty as e:

View File

@@ -1,165 +0,0 @@
# backend/notifications/summary.py
import logging
from collections import defaultdict
from datetime import datetime
from typing import TYPE_CHECKING
from autogpt_libs.utils.cache import thread_cached
if TYPE_CHECKING:
from backend.executor.database import DatabaseManager
from backend.data.notifications import (
DailySummaryData,
MonthlySummaryData,
NotificationEventModel,
NotificationType,
WeeklySummaryData,
)
from backend.util.service import get_service_client
logger = logging.getLogger(__name__)
class SummaryManager:
"""Handles all summary generation and stats collection"""
def __init__(self):
self.summary_keys = {
"daily": "summary:daily:",
"weekly": "summary:weekly:",
"monthly": "summary:monthly:",
}
self.last_check_keys = {
"daily": "summary:last_check:daily",
"weekly": "summary:last_check:weekly",
"monthly": "summary:last_check:monthly",
}
async def collect_stats(
self, user_id: str, start_time: datetime, end_time: datetime
) -> dict:
"""Collect execution statistics for a time period"""
db = get_db_client()
executions = db.get_executions_in_timerange(
user_id=user_id,
start_time=start_time.isoformat(),
end_time=end_time.isoformat(),
)
stats = {
"total_credits_used": 0,
"total_executions": len(executions),
"total_execution_time": 0,
"successful_runs": 0,
"failed_runs": 0,
"agent_usage": defaultdict(float),
}
# for execution in executions:
# stats["total_credits_used"] += execution.credits_used
# stats["total_execution_time"] += execution.execution_time
# stats[
# "successful_runs" if execution.status == "completed" else "failed_runs"
# ] += 1
# stats["agent_usage"][execution.agent_type] += execution.credits_used
most_used = (
max(stats["agent_usage"].items(), key=lambda x: x[1])[0]
if stats["agent_usage"]
else "None"
)
return {
"total_credits_used": stats["total_credits_used"],
"total_executions": stats["total_executions"],
"most_used_agent": most_used,
"total_execution_time": stats["total_execution_time"],
"successful_runs": stats["successful_runs"],
"failed_runs": stats["failed_runs"],
"average_execution_time": (
stats["total_execution_time"] / stats["total_executions"]
if stats["total_executions"]
else 0
),
"cost_breakdown": dict(stats["agent_usage"]),
}
async def should_generate_summary(self, summary_type: str, redis) -> bool:
"""Check if we should generate a summary based on last check time"""
last_check_key = self.last_check_keys[summary_type]
last_check = await redis.get(last_check_key)
if not last_check:
return True
last_check_time = datetime.fromisoformat(last_check)
now = datetime.now()
if summary_type == "daily":
return now.date() != last_check_time.date()
elif summary_type == "weekly":
return now.isocalendar()[1] != last_check_time.isocalendar()[1]
else: # monthly
return now.month != last_check_time.month
async def generate_summary(
self,
summary_type: str,
user_id: str,
start_time: datetime,
end_time: datetime,
notification_manager,
) -> bool:
"""Generate and send a summary for a user"""
try:
stats = await self.collect_stats(user_id, start_time, end_time)
if not stats["total_executions"]:
return False
if summary_type == "daily":
data = DailySummaryData(date=start_time, **stats)
type_ = NotificationType.DAILY_SUMMARY
notification = NotificationEventModel(
user_id=user_id,
type=type_,
data=data,
)
elif summary_type == "weekly":
data = WeeklySummaryData(
start_date=start_time,
end_date=end_time,
week_number=start_time.isocalendar()[1],
year=start_time.year,
**stats,
)
type_ = NotificationType.WEEKLY_SUMMARY
notification = NotificationEventModel(
user_id=user_id,
type=type_,
data=data,
)
else:
data = MonthlySummaryData(
month=start_time.month, year=start_time.year, **stats
)
type_ = NotificationType.MONTHLY_SUMMARY
notification = NotificationEventModel(
user_id=user_id,
type=type_,
data=data,
)
return await notification_manager._process_immediate(notification)
except Exception as e:
logger.error(
f"Error generating {summary_type} summary for user {user_id}: {e}"
)
return False
@thread_cached
def get_db_client() -> "DatabaseManager":
from backend.executor import DatabaseManager
return get_service_client(DatabaseManager)

View File

@@ -40,7 +40,13 @@ from backend.data.credit import (
get_user_credit_model,
set_auto_top_up,
)
from backend.data.user import get_or_create_user
from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO
from backend.data.user import (
get_or_create_user,
get_user_notification_preference,
update_user_email,
update_user_notification_preference,
)
from backend.executor import ExecutionManager, ExecutionScheduler, scheduler
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.webhooks.graph_lifecycle_hooks import (
@@ -108,6 +114,41 @@ async def get_or_create_user_route(user_data: dict = Depends(auth_middleware)):
return user.model_dump()
@v1_router.post(
"/auth/user/email", tags=["auth"], dependencies=[Depends(auth_middleware)]
)
async def update_user_email_route(
user_id: Annotated[str, Depends(get_user_id)], email: str = Body(...)
) -> dict[str, str]:
await update_user_email(user_id, email)
return {"email": email}
@v1_router.get(
"/auth/user/preferences",
tags=["auth"],
dependencies=[Depends(auth_middleware)],
)
async def get_preferences(
user_id: Annotated[str, Depends(get_user_id)],
) -> NotificationPreference:
return await get_user_notification_preference(user_id)
@v1_router.post(
"/auth/user/preferences",
tags=["auth"],
dependencies=[Depends(auth_middleware)],
)
async def update_preferences(
user_id: Annotated[str, Depends(get_user_id)],
preferences: NotificationPreferenceDTO = Body(...),
) -> NotificationPreference:
output = await update_user_notification_preference(user_id, preferences)
return output
########################################################
##################### Blocks ###########################
########################################################

View File

@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import BackendApi from "@/lib/autogpt-server-api";
import { NotificationPreferenceDTO } from "@/lib/autogpt-server-api/types";
export async function updateSettings(formData: FormData) {
const supabase = getServerSupabase();
const {
data: { user },
} = await supabase.auth.getUser();
// Handle auth-related updates
const password = formData.get("password") as string;
const email = formData.get("email") as string;
if (password) {
const { error: passwordError } = await supabase.auth.updateUser({
password,
});
if (passwordError) {
throw new Error(`${passwordError.message}`);
}
}
if (email !== user?.email) {
const { error: emailError } = await supabase.auth.updateUser({
email,
});
const api = new BackendApi();
await api.updateUserEmail(email);
if (emailError) {
throw new Error(`${emailError.message}`);
}
}
const preferencesError = {};
if (preferencesError) {
throw new SettingsError(
`Failed to update preferences: ${preferencesError.message}`,
);
}
const api = new BackendApi();
const preferences: NotificationPreferenceDTO = {
email: user?.email || "",
preferences: {
agent_run: formData.get("notifyOnAgentRun") === "true",
zero_balance: formData.get("notifyOnZeroBalance") === "true",
low_balance: formData.get("notifyOnLowBalance") === "true",
block_execution_failed:
formData.get("notifyOnBlockExecutionFailed") === "true",
continuous_agent_error:
formData.get("notifyOnContinuousAgentError") === "true",
daily_summary: formData.get("notifyOnDailySummary") === "true",
weekly_summary: formData.get("notifyOnWeeklySummary") === "true",
monthly_summary: formData.get("notifyOnMonthlySummary") === "true",
},
daily_limit: 0,
};
await api.updateUserPreferences(preferences);
revalidatePath("/profile/settings");
return { success: true };
}

View File

@@ -0,0 +1,55 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
export default function SettingsLoading() {
return (
<div className="container max-w-2xl py-10">
<div className="space-y-6">
<div>
<Skeleton className="h-6 w-32" />
<Skeleton className="mt-2 h-4 w-96" />
</div>
<div className="space-y-8">
<div className="space-y-4">
{/* Email and Password fields */}
{[1, 2].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
<Separator />
<div className="space-y-4">
<Skeleton className="h-6 w-28" />
{/* Agent Notifications */}
<div className="space-y-4">
<Skeleton className="h-4 w-36" />
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex flex-row items-center justify-between rounded-lg p-4"
>
<div className="space-y-0.5">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-6 w-11" />
</div>
))}
</div>
</div>
<div className="flex items-center justify-end space-x-4">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-32" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,30 @@
import * as React from "react";
import { SettingsInputForm } from "@/components/agptui/SettingsInputForm";
import { Metadata } from "next";
import SettingsForm from "@/components/profile/settings/SettingsForm";
import getServerUser from "@/lib/supabase/getServerUser";
import { redirect } from "next/navigation";
export default function Page() {
return <SettingsInputForm />;
export const metadata: Metadata = {
title: "Settings",
description: "Manage your account settings and preferences.",
};
export default async function SettingsPage() {
const { user, error } = await getServerUser();
if (error || !user) {
redirect("/login");
}
return (
<div className="container max-w-2xl space-y-6 py-10">
<div>
<h3 className="text-lg font-medium">My account</h3>
<p className="text-sm text-muted-foreground">
Manage your account settings and preferences.
</p>
</div>
<SettingsForm user={user} />
</div>
);
}

View File

@@ -1,23 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SettingsInputForm } from "./SettingsInputForm";
const meta: Meta<typeof SettingsInputForm> = {
title: "AGPT UI/Settings/Settings Input Form",
component: SettingsInputForm,
parameters: {
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj<typeof SettingsInputForm>;
export const Default: Story = {
args: {
email: "johndoe@email.com",
desktopNotifications: {
first: false,
second: true,
},
},
};

View File

@@ -1,134 +0,0 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import useSupabase from "@/hooks/useSupabase";
interface SettingsInputFormProps {
email?: string;
desktopNotifications?: {
first: boolean;
second: boolean;
};
}
export const SettingsInputForm = ({
email = "johndoe@email.com",
desktopNotifications = { first: false, second: true },
}: SettingsInputFormProps) => {
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [passwordsMatch, setPasswordsMatch] = React.useState(true);
const { supabase } = useSupabase();
const handleSaveChanges = async () => {
if (password !== confirmPassword) {
setPasswordsMatch(false);
return;
}
setPasswordsMatch(true);
if (supabase) {
try {
const { error } = await supabase.auth.updateUser({
password: password,
});
if (error) {
console.error("Error updating user:", error);
} else {
console.log("User updated successfully");
}
} catch (error) {
console.error("Error updating user:", error);
}
}
};
const handleCancel = () => {
setPassword("");
setConfirmPassword("");
setPasswordsMatch(true);
};
return (
<div className="mx-auto w-full max-w-[1077px] bg-white px-4 pt-8 dark:bg-neutral-900 sm:px-6 sm:pt-16">
<h1 className="mb-8 text-2xl font-semibold text-slate-950 dark:text-slate-200 sm:mb-16 sm:text-3xl">
Settings
</h1>
{/* My Account Section */}
<section aria-labelledby="account-heading">
<h2
id="account-heading"
className="mb-8 text-lg font-medium text-neutral-500 dark:text-neutral-400 sm:mb-12"
>
My account
</h2>
<div className="flex max-w-[800px] flex-col gap-7">
{/* Password Input */}
<div className="relative">
<div className="flex flex-col gap-1.5">
<label
htmlFor="password-input"
className="text-base font-medium text-slate-950 dark:text-slate-200"
>
Password
</label>
<input
id="password-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-[50px] w-full rounded-[35px] border border-neutral-200 bg-transparent px-6 py-3 text-base text-slate-950 dark:border-neutral-700 dark:text-white"
aria-label="Password field"
/>
</div>
</div>
{/* Confirm Password Input */}
<div className="relative">
<div className="flex flex-col gap-1.5">
<label
htmlFor="confirm-password-input"
className="text-base font-medium text-slate-950 dark:text-slate-200"
>
Confirm Password
</label>
<input
id="confirm-password-input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-[50px] w-full rounded-[35px] border border-neutral-200 bg-transparent px-6 py-3 text-base text-slate-950 dark:border-neutral-700 dark:text-white"
aria-label="Confirm Password field"
/>
</div>
</div>
</div>
</section>
<div
className="my-8 border-t border-neutral-200 dark:border-neutral-700 sm:my-12"
role="separator"
/>
<div className="mt-8 flex justify-end">
<div className="flex gap-3">
<Button
variant="secondary"
className="h-[50px] rounded-[35px] bg-neutral-200 px-6 py-3 font-['Geist'] text-base font-medium text-neutral-800 transition-colors hover:bg-neutral-300 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="default"
className="h-[50px] rounded-[35px] bg-neutral-800 px-6 py-3 font-['Geist'] text-base font-medium text-white transition-colors hover:bg-neutral-900 dark:bg-neutral-900 dark:hover:bg-neutral-800"
onClick={handleSaveChanges}
>
Save changes
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,397 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { User } from "@supabase/supabase-js";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { updateSettings } from "@/app/profile/(user)/settings/actions";
import { toast } from "@/components/ui/use-toast";
const formSchema = z
.object({
email: z.string().email(),
password: z
.string()
.optional()
.refine((val) => {
// If password is provided, it must be at least 8 characters
if (val) return val.length >= 8;
return true;
}, "String must contain at least 8 character(s)"),
confirmPassword: z.string().optional(),
notifyOnAgentRun: z.boolean(),
notifyOnZeroBalance: z.boolean(),
notifyOnLowBalance: z.boolean(),
notifyOnBlockExecutionFailed: z.boolean(),
notifyOnContinuousAgentError: z.boolean(),
notifyOnDailySummary: z.boolean(),
notifyOnWeeklySummary: z.boolean(),
notifyOnMonthlySummary: z.boolean(),
})
.refine(
(data) => {
if (data.password || data.confirmPassword) {
return data.password === data.confirmPassword;
}
return true;
},
{
message: "Passwords do not match",
path: ["confirmPassword"],
},
);
interface SettingsFormProps {
user: User;
}
export default function SettingsForm({ user }: SettingsFormProps) {
const defaultValues = {
email: user.email || "",
password: "",
confirmPassword: "",
notifyOnAgentRun: true,
notifyOnZeroBalance: true,
notifyOnLowBalance: true,
notifyOnBlockExecutionFailed: true,
notifyOnContinuousAgentError: true,
notifyOnDailySummary: true,
notifyOnWeeklySummary: true,
notifyOnMonthlySummary: true,
};
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues,
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const formData = new FormData();
Object.entries(values).forEach(([key, value]) => {
if (key !== "confirmPassword") {
formData.append(key, value.toString());
}
});
await updateSettings(formData);
toast({
title: "Successfully updated settings",
});
form.reset(defaultValues);
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Something went wrong",
variant: "destructive",
});
throw error;
}
}
function onCancel() {
form.reset(defaultValues);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-8"
>
{/* Account Settings Section */}
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="************"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="************"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Notifications Section */}
<div className="flex flex-col gap-6">
<h3 className="text-lg font-medium">Notifications</h3>
{/* Agent Notifications */}
<div className="flex flex-col gap-4">
<h4 className="text-sm font-medium text-muted-foreground">
Agent Notifications
</h4>
<FormField
control={form.control}
name="notifyOnAgentRun"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Agent Run Notifications
</FormLabel>
<FormDescription>
Receive notifications when an agent starts or completes a
run
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnBlockExecutionFailed"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Block Execution Failures
</FormLabel>
<FormDescription>
Get notified when a block execution fails during agent
runs
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnContinuousAgentError"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Continuous Agent Errors
</FormLabel>
<FormDescription>
Receive alerts when an agent encounters repeated errors
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Balance Notifications */}
<div className="flex flex-col gap-4">
<h4 className="text-sm font-medium text-muted-foreground">
Balance Notifications
</h4>
<FormField
control={form.control}
name="notifyOnZeroBalance"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Zero Balance Alert
</FormLabel>
<FormDescription>
Get notified when your account balance reaches zero
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnLowBalance"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Low Balance Warning
</FormLabel>
<FormDescription>
Receive warnings when your balance is running low
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Summary Reports */}
<div className="flex flex-col gap-4">
<h4 className="text-sm font-medium text-muted-foreground">
Summary Reports
</h4>
<FormField
control={form.control}
name="notifyOnDailySummary"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Daily Summary</FormLabel>
<FormDescription>
Receive a daily summary of your account activity
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnWeeklySummary"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Weekly Summary</FormLabel>
<FormDescription>
Get a weekly overview of your account performance
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notifyOnMonthlySummary"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Monthly Summary</FormLabel>
<FormDescription>
Receive a comprehensive monthly report of your account
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={onCancel}
disabled={form.formState.isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting || !form.formState.isDirty}
>
{form.formState.isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -32,7 +32,9 @@ import {
StoreReview,
TransactionHistory,
User,
NotificationPreferenceDTO,
UserPasswordCredentials,
NotificationPreference,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
import getServerSupabase from "../supabase/getServerSupabase";
@@ -81,6 +83,10 @@ export default class BackendAPI {
return this._request("POST", "/auth/user", {});
}
updateUserEmail(email: string): Promise<{ email: string }> {
return this._request("POST", "/auth/user/email", { email });
}
getUserCredit(page?: string): Promise<{ credits: number }> {
try {
return this._get(`/credits`, undefined, page);
@@ -89,6 +95,16 @@ export default class BackendAPI {
}
}
getUserPreferences(): Promise<NotificationPreference> {
return this._get("/auth/user/preferences");
}
updateUserPreferences(
preferences: NotificationPreferenceDTO,
): Promise<NotificationPreference> {
return this._request("POST", "/auth/user/preferences", preferences);
}
getAutoTopUpConfig(): Promise<{ amount: number; threshold: number }> {
return this._get("/credits/auto-top-up");
}

View File

@@ -346,6 +346,30 @@ export type UserPasswordCredentials = BaseCredentials & {
password: string;
};
// Mirror of backend/backend/data/notifications.py:NotificationType
export type NotificationType =
| "agent_run"
| "zero_balance"
| "low_balance"
| "block_execution_failed"
| "continuous_agent_error"
| "daily_summary"
| "weekly_summary"
| "monthly_summary";
// Mirror of backend/backend/data/notifications.py:NotificationPreference
export type NotificationPreferenceDTO = {
email: string;
preferences: { [key in NotificationType]: boolean };
daily_limit: number;
};
export type NotificationPreference = NotificationPreferenceDTO & {
user_id: string;
emails_sent_today: number;
last_reset_date: Date;
};
/* Mirror of backend/data/integrations.py:Webhook */
export type Webhook = {
id: string;