added feedback system with admin management

This commit is contained in:
Oliver Kieselbach
2026-03-18 18:44:01 +01:00
parent f756f7081d
commit bc84504a32
15 changed files with 882 additions and 31 deletions
@@ -1 +1 @@
489
490
@@ -0,0 +1,249 @@
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Feedback
{
public class FeedbackFunction
{
private readonly ILogger<FeedbackFunction> _logger;
private readonly TenantConfigurationService _tenantConfigService;
private readonly AdminConfigurationService _adminConfigService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly TableStorageService _storageService;
private readonly TableClient _feedbackTableClient;
public FeedbackFunction(
ILogger<FeedbackFunction> logger,
TenantConfigurationService tenantConfigService,
AdminConfigurationService adminConfigService,
TenantAdminsService tenantAdminsService,
TelegramNotificationService telegramNotificationService,
TableStorageService storageService,
IConfiguration configuration)
{
_logger = logger;
_tenantConfigService = tenantConfigService;
_adminConfigService = adminConfigService;
_tenantAdminsService = tenantAdminsService;
_telegramNotificationService = telegramNotificationService;
_storageService = storageService;
var connectionString = configuration["AzureTableStorageConnectionString"];
var serviceClient = new TableServiceClient(connectionString);
_feedbackTableClient = serviceClient.GetTableClient(Constants.TableNames.PreviewConfig);
}
/// <summary>
/// Checks whether the current user is eligible to see the feedback bubble.
/// Evaluates: kill-switch, role, tenant age, session existence, cooldown.
/// </summary>
[Function("GetFeedbackStatus")]
public async Task<HttpResponseData> GetStatus(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/status")] HttpRequestData req)
{
try
{
// 1. Kill-switch
var adminConfig = await _adminConfigService.GetConfigurationAsync();
if (!adminConfig.FeedbackEnabled)
return await WriteJson(req, new { eligible = false });
// 2. User identity
string tenantId = TenantHelper.GetTenantId(req);
string upn = TenantHelper.GetUserIdentifier(req);
// 3. Role check — only Admin + Operator
var roleInfo = await _tenantAdminsService.GetMemberRoleAsync(tenantId, upn);
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
return await WriteJson(req, new { eligible = false });
// 4. Tenant age check
var tenantConfig = await _tenantConfigService.GetConfigurationAsync(tenantId);
if (tenantConfig.OnboardedAt == null ||
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
return await WriteJson(req, new { eligible = false });
// 5. Sessions check — at least 1 session exists
var sessionPage = await _storageService.GetSessionsAsync(tenantId, maxResults: 1);
if (sessionPage.Sessions.Count == 0)
return await WriteJson(req, new { eligible = false });
// 6. Cooldown check
try
{
var entity = await _feedbackTableClient.GetEntityAsync<TableEntity>("Feedback", upn.ToLowerInvariant());
var interactedAt = entity.Value.GetDateTime("InteractedAt");
if (interactedAt.HasValue)
{
// Cooldown = 0 means single wave only
if (adminConfig.FeedbackCooldownDays == 0)
return await WriteJson(req, new { eligible = false });
var daysSinceInteraction = (DateTime.UtcNow - interactedAt.Value).TotalDays;
if (daysSinceInteraction < adminConfig.FeedbackCooldownDays)
return await WriteJson(req, new { eligible = false });
}
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// No feedback record yet — user is eligible
}
return await WriteJson(req, new { eligible = true });
}
catch (UnauthorizedAccessException)
{
var response = req.CreateResponse(HttpStatusCode.Unauthorized);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking feedback status");
// Fail closed — don't show bubble on errors
return await WriteJson(req, new { eligible = false });
}
}
/// <summary>
/// Submits user feedback or records a dismissal.
/// On submit: stores feedback and sends Telegram notification.
/// On dismiss: stores dismissal so the bubble is not shown again (until cooldown).
/// </summary>
[Function("SubmitFeedback")]
public async Task<HttpResponseData> Submit(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "feedback")] HttpRequestData req)
{
try
{
string tenantId = TenantHelper.GetTenantId(req);
string upn = TenantHelper.GetUserIdentifier(req);
var principal = req.FunctionContext.GetUser();
string displayName = principal?.GetDisplayName() ?? upn;
var body = await req.ReadFromJsonAsync<FeedbackRequest>();
if (body == null)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid request body" });
return badRequest;
}
// Validate rating
if (!body.Dismissed && (body.Rating < 1 || body.Rating > 5))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "Rating must be between 1 and 5" });
return badRequest;
}
// Trim comment
var comment = body.Comment?.Trim();
if (comment?.Length > 500)
comment = comment.Substring(0, 500);
// Upsert feedback record
var entity = new TableEntity("Feedback", upn.ToLowerInvariant())
{
{ "TenantId", tenantId },
{ "DisplayName", displayName },
{ "Rating", body.Dismissed ? null : (int?)body.Rating },
{ "Comment", body.Dismissed ? null : comment },
{ "Dismissed", body.Dismissed },
{ "Submitted", !body.Dismissed },
{ "InteractedAt", DateTime.UtcNow }
};
await _feedbackTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
// Telegram notification — only for actual submissions, fire-and-forget
if (!body.Dismissed && body.Rating > 0)
{
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, body.Rating, comment);
}
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
return await WriteJson(req, new { success = true });
}
catch (UnauthorizedAccessException)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error submitting feedback");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return errorResponse;
}
}
/// <summary>
/// Returns all feedback entries for the Galactic Admin dashboard.
/// </summary>
[Function("GetAllFeedback")]
public async Task<HttpResponseData> GetAll(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "feedback/all")] HttpRequestData req)
{
try
{
var entries = new System.Collections.Generic.List<object>();
await foreach (var entity in _feedbackTableClient.QueryAsync<TableEntity>(
filter: "PartitionKey eq 'Feedback'"))
{
entries.Add(new
{
upn = entity.RowKey,
tenantId = entity.GetString("TenantId"),
displayName = entity.GetString("DisplayName"),
rating = entity.GetInt32("Rating"),
comment = entity.GetString("Comment"),
dismissed = entity.GetBoolean("Dismissed") ?? false,
submitted = entity.GetBoolean("Submitted") ?? false,
interactedAt = entity.GetDateTime("InteractedAt")?.ToString("o")
});
}
return await WriteJson(req, new { feedback = entries });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching all feedback");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return errorResponse;
}
}
private static async Task<HttpResponseData> WriteJson(HttpRequestData req, object data)
{
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(data);
return response;
}
}
public class FeedbackRequest
{
public int Rating { get; set; }
public string? Comment { get; set; }
public bool Dismissed { get; set; }
}
}
@@ -97,6 +97,8 @@ public static class EndpointAccessPolicyCatalog
new("GET", "progress/sessions", EndpointPolicy.AuthenticatedUser),
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser),
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
// ── MemberRead (Admin + Operator, later + Viewer) ───────────────
new("GET", "sessions", EndpointPolicy.MemberRead),
@@ -179,6 +181,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "health/detailed", EndpointPolicy.AuthenticatedUser),
new("POST", "rules/reseed-from-github", EndpointPolicy.GalacticAdminOnly),
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GalacticAdminOnly),
new("GET", "feedback/all", EndpointPolicy.GalacticAdminOnly),
new("GET", "galactic/notifications", EndpointPolicy.GalacticAdminOnly),
new("POST", "galactic/notifications/dismiss-all", EndpointPolicy.GalacticAdminOnly),
new("POST", "galactic/notifications/{notificationId}/dismiss", EndpointPolicy.GalacticAdminOnly),
@@ -45,6 +45,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly UsageMetricsService _usageMetricsService;
private readonly AdminConfigurationService _adminConfigurationService;
private readonly BlockedDeviceService _blockedDeviceService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly ILogger<MaintenanceService> _logger;
private const string PlatformStatsAliasFileName = "platform-stats.json";
@@ -57,6 +58,7 @@ namespace AutopilotMonitor.Functions.Services
UsageMetricsService usageMetricsService,
AdminConfigurationService adminConfigurationService,
BlockedDeviceService blockedDeviceService,
TenantAdminsService tenantAdminsService,
ILogger<MaintenanceService> logger)
{
_storageService = storageService;
@@ -64,6 +66,7 @@ namespace AutopilotMonitor.Functions.Services
_usageMetricsService = usageMetricsService;
_adminConfigurationService = adminConfigurationService;
_blockedDeviceService = blockedDeviceService;
_tenantAdminsService = tenantAdminsService;
_logger = logger;
}
@@ -86,6 +89,9 @@ namespace AutopilotMonitor.Functions.Services
// Safety net: backfill any sessions missing from SessionsIndex
await _storageService.BackfillSessionIndexAsync();
// Backfill OnboardedAt for tenants that don't have it yet
await BackfillTenantOnboardedAtAsync();
maintenanceStart.Stop();
_logger.LogInformation($"Daily maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms");
}
@@ -317,6 +323,61 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Backfills OnboardedAt for tenants that don't have it set yet.
/// Derives the value from the earliest TenantAdmin AddedDate for each tenant.
/// Self-healing: runs every maintenance cycle, no-ops once all tenants are backfilled.
/// </summary>
private async Task BackfillTenantOnboardedAtAsync()
{
_logger.LogInformation("Backfilling OnboardedAt for tenants...");
try
{
var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync();
var configsWithoutOnboardedAt = allConfigs.Where(c => c.OnboardedAt == null).ToList();
if (configsWithoutOnboardedAt.Count == 0)
{
_logger.LogInformation("All tenants already have OnboardedAt set");
return;
}
int backfilledCount = 0;
foreach (var config in configsWithoutOnboardedAt)
{
try
{
var admins = await _tenantAdminsService.GetTenantAdminsAsync(config.TenantId);
if (admins.Count == 0)
{
// No admins yet — use LastUpdated as fallback (config creation date)
config.OnboardedAt = config.LastUpdated;
}
else
{
config.OnboardedAt = admins.Min(a => a.AddedDate);
}
await _tenantConfigService.SaveConfigurationAsync(config);
backfilledCount++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to backfill OnboardedAt for tenant {TenantId}", config.TenantId);
}
}
_logger.LogInformation("OnboardedAt backfill completed: {Count} tenants updated", backfilledCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to backfill OnboardedAt for tenants");
}
}
/// <summary>
/// Aggregates metrics for any missed days in the last 7 days, plus yesterday.
/// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation.
@@ -155,6 +155,44 @@ namespace AutopilotMonitor.Functions.Services
return false;
}
/// <summary>
/// Sends a Telegram notification when a user submits feedback via the in-app feedback bubble.
/// Best-effort — silently no-ops if the webhook URL is not configured.
/// </summary>
public async Task SendFeedbackAsync(string tenantId, string upn, string displayName, int rating, string? comment)
{
try
{
var webhookUrl = await GetWebhookUrlAsync();
if (string.IsNullOrWhiteSpace(webhookUrl))
{
_logger.LogDebug("Telegram webhook URL not configured — skipping feedback notification");
return;
}
var stars = new string('\u2605', rating) + new string('\u2606', 5 - rating);
var commentLine = string.IsNullOrWhiteSpace(comment) ? "" : $"\nComment: {comment}";
var payload = new
{
chat_id = "-1003632442830",
text = $"New Feedback!\nRating: {stars} ({rating}/5)\nUser: {displayName} ({upn})\nTenant: {tenantId}{commentLine}"
};
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var sent = await PostWithRetryAsync(webhookUrl, content, $"feedback:{upn}");
if (sent)
{
_logger.LogInformation("Telegram feedback notification sent for user {Upn}", upn);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send Telegram feedback notification for {Upn}", upn);
}
}
private async Task<string?> GetWebhookUrlAsync()
{
try
@@ -242,7 +242,8 @@ namespace AutopilotMonitor.Functions.Services
{ "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson },
{ "BootstrapTokenEnabled", config.BootstrapTokenEnabled },
{ "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled },
{ "UnrestrictedMode", config.UnrestrictedMode }
{ "UnrestrictedMode", config.UnrestrictedMode },
{ "OnboardedAt", config.OnboardedAt }
};
return entity;
@@ -303,7 +304,8 @@ namespace AutopilotMonitor.Functions.Services
LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"),
BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false,
UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false,
UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false
UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false,
OnboardedAt = entity.GetDateTime("OnboardedAt")
};
}
}
@@ -72,6 +72,29 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int MaintenanceBlockDurationHours { get; set; } = 12;
// ===== FEEDBACK SETTINGS =====
/// <summary>
/// Global kill-switch for the in-app feedback prompt.
/// When false, no user sees the feedback bubble regardless of other settings.
/// Default: true.
/// </summary>
public bool FeedbackEnabled { get; set; } = true;
/// <summary>
/// Minimum tenant age in days before users are prompted for feedback.
/// Prevents asking brand-new tenants who haven't had meaningful experience yet.
/// Default: 14 days.
/// </summary>
public int FeedbackMinTenantAgeDays { get; set; } = 14;
/// <summary>
/// Cooldown in days after a user interacts with the feedback prompt
/// before they are prompted again. 0 = never re-prompt (single wave only).
/// Default: 60 days.
/// </summary>
public int FeedbackCooldownDays { get; set; } = 60;
// ===== DIAGNOSTICS LOG PATHS =====
/// <summary>
@@ -35,6 +35,13 @@ namespace AutopilotMonitor.Shared.Models
// ===== TENANT STATUS =====
/// <summary>
/// When this tenant was first onboarded (derived from earliest TenantAdmin AddedDate).
/// Used for feedback eligibility checks (tenant must be old enough before prompting).
/// Backfilled by the maintenance job for existing tenants; set to UtcNow for new tenants.
/// </summary>
public DateTime? OnboardedAt { get; set; }
/// <summary>
/// Whether this tenant is disabled/suspended
/// If true, users from this tenant cannot log in
@@ -511,7 +518,8 @@ namespace AutopilotMonitor.Shared.Models
PerformanceCollectorIntervalSeconds = 30,
SelfDestructOnComplete = true,
KeepLogFile = false,
ShowScriptOutput = true
ShowScriptOutput = true,
OnboardedAt = DateTime.UtcNow
};
}
}
@@ -0,0 +1,239 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface FeedbackEntry {
upn: string;
tenantId: string;
displayName: string;
rating: number | null;
comment: string | null;
dismissed: boolean;
submitted: boolean;
interactedAt: string | null;
}
interface FeedbackSectionProps {
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
}
export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionProps) {
const [expanded, setExpanded] = useState(false);
const [loading, setLoading] = useState(false);
const [entries, setEntries] = useState<FeedbackEntry[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const entriesPerPage = 5;
const fetchFeedback = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await authenticatedFetch(`${API_BASE_URL}/api/feedback/all`, getAccessToken);
if (!response.ok) {
throw new Error(`Failed to load feedback: ${response.statusText}`);
}
const data = await response.json();
// Sort by interactedAt descending (newest first)
const sorted = (data.feedback || []).sort((a: FeedbackEntry, b: FeedbackEntry) => {
const dateA = a.interactedAt ? new Date(a.interactedAt).getTime() : 0;
const dateB = b.interactedAt ? new Date(b.interactedAt).getTime() : 0;
return dateB - dateA;
});
setEntries(sorted);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while fetching feedback");
} else {
console.error("Error fetching feedback:", err);
}
setError(err instanceof Error ? err.message : "Failed to load feedback");
} finally {
setLoading(false);
}
}, [getAccessToken, setError]);
useEffect(() => {
if (expanded && entries.length === 0) {
fetchFeedback();
}
}, [expanded, entries.length, fetchFeedback]);
// Stats
const submittedEntries = entries.filter(e => e.submitted);
const dismissedEntries = entries.filter(e => e.dismissed && !e.submitted);
const avgRating = submittedEntries.length > 0
? (submittedEntries.reduce((sum, e) => sum + (e.rating || 0), 0) / submittedEntries.length).toFixed(1)
: "—";
// Pagination
const totalPages = Math.ceil(entries.length / entriesPerPage);
const paginatedEntries = entries.slice(
currentPage * entriesPerPage,
(currentPage + 1) * entriesPerPage
);
const formatTimeAgo = (dateStr: string | null): string => {
if (!dateStr) return "unknown";
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
return `${weeks}w ago`;
};
const renderStars = (rating: number | null) => {
if (rating == null) return null;
return (
<span className="inline-flex gap-0.5">
{[1, 2, 3, 4, 5].map(s => (
<svg
key={s}
className={`w-4 h-4 ${s <= rating ? "text-yellow-400" : "text-gray-300 dark:text-gray-600"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</span>
);
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-purple-200 dark:border-purple-800">
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-purple-600 dark:text-purple-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
<div className="text-left">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">User Feedback</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">In-app feedback from tenant admins and operators</p>
</div>
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${expanded ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Content */}
{expanded && (
<div className="px-6 pb-6 border-t border-gray-200 dark:border-gray-700">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : entries.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No feedback received yet
</div>
) : (
<>
{/* Stats */}
<div className="flex flex-wrap items-center gap-4 py-4 text-sm">
<span className="text-gray-600 dark:text-gray-300">
<span className="font-semibold text-purple-600 dark:text-purple-400">{submittedEntries.length}</span> Submitted
</span>
<span className="text-gray-400">|</span>
<span className="text-gray-600 dark:text-gray-300">
<span className="font-semibold text-gray-500">{dismissedEntries.length}</span> Dismissed
</span>
<span className="text-gray-400">|</span>
<span className="text-gray-600 dark:text-gray-300">
Avg <span className="font-semibold text-yellow-500">{avgRating}</span>
</span>
<button
onClick={fetchFeedback}
className="ml-auto text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
>
Refresh
</button>
</div>
{/* Entries */}
<div className="space-y-2">
{paginatedEntries.map((entry) => (
<div
key={entry.upn}
className={`border rounded-lg p-3 transition-all ${
entry.dismissed && !entry.submitted
? "bg-gray-50 dark:bg-gray-750 border-gray-200 dark:border-gray-700 opacity-60"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{entry.upn}
</span>
{entry.submitted ? renderStars(entry.rating) : (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">dismissed</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{formatTimeAgo(entry.interactedAt)}</span>
<span className="hidden sm:inline">·</span>
<span className="hidden sm:inline truncate max-w-[120px]">
{entry.tenantId.substring(0, 8)}...
</span>
</div>
</div>
{entry.comment && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300 italic">
&quot;{entry.comment}&quot;
</p>
)}
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
<button
onClick={() => setCurrentPage(p => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-650 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-600 dark:text-gray-400">
Page {currentPage + 1} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages - 1, p + 1))}
disabled={currentPage >= totalPages - 1}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-650 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
@@ -14,9 +14,10 @@ import { VersionBlockSection } from "./components/VersionBlockSection";
import { SessionExportSection } from "./components/SessionExportSection";
import { AdminConfigSettingsSection } from "./components/AdminConfigSettingsSection";
import { SessionReportsSection } from "./components/SessionReportsSection";
import { FeedbackSection } from "./components/FeedbackSection";
import { usePageSections } from "../../hooks/usePageSections";
import { PageSectionItem } from "../../contexts/SidebarContext";
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon } from "../../lib/sidebarIcons";
import { BuildingOfficeIcon, DocumentTextIcon, ArrowDownTrayIcon, GearIcon, FolderIcon, WrenchScrewdriverIcon, NoSymbolIcon, ArrowPathIcon, SparklesIcon } from "../../lib/sidebarIcons";
interface AdminConfiguration {
partitionKey: string;
@@ -253,6 +254,7 @@ export default function AdminConfigurationPage() {
const adminSections: PageSectionItem[] = useMemo(() => [
{ id: "tenant-management", label: "Tenant Management", icon: <BuildingOfficeIcon /> },
{ id: "session-reports", label: "Session Reports", icon: <DocumentTextIcon /> },
{ id: "user-feedback", label: "User Feedback", icon: <SparklesIcon /> },
{ id: "session-export", label: "Session Export", icon: <ArrowDownTrayIcon /> },
{ id: "global-settings", label: "Global Settings", icon: <GearIcon /> },
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", icon: <FolderIcon /> },
@@ -331,6 +333,14 @@ export default function AdminConfigurationPage() {
/>
</div>
{/* User Feedback */}
<div id="user-feedback">
<FeedbackSection
getAccessToken={getAccessToken}
setError={setError}
/>
</div>
{/* Session Event Export */}
<div id="session-export">
<SessionExportSection
@@ -633,3 +633,19 @@ html:hover ::-webkit-scrollbar-thumb {
.landing-page .dot-pulse {
animation: dotPulse 12.6s linear infinite;
}
/* ===== FEEDBACK BUBBLE ANIMATION ===== */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out both;
}
@@ -9,6 +9,7 @@ import { GalacticNotificationProvider } from "../contexts/GalacticNotificationCo
import { ThemeProvider } from "../contexts/ThemeContext";
import Navbar from "../components/Navbar";
import ScrollToTopButton from "../components/ScrollToTopButton";
import FeedbackBubble from "../components/FeedbackBubble";
import { SidebarProvider } from "../contexts/SidebarContext";
import { GlobalSidebar } from "../components/GlobalSidebar";
@@ -125,6 +126,7 @@ export default function RootLayout({
{children}
</GlobalSidebar>
<ScrollToTopButton />
<FeedbackBubble />
</SidebarProvider>
</SignalRProvider>
</TenantProvider>
@@ -349,33 +349,32 @@ export default function AgentSettingsSection({
</>
)}
{/* Save / Reset */}
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
<button
onClick={onReset}
disabled={saving}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Reset
</button>
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Saving...</span>
</>
) : (
<span>Save</span>
)}
</button>
</div>
</div>
</div>
{/* Save / Reset */}
<div className="flex items-center justify-end space-x-3">
<button
onClick={onReset}
disabled={saving}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Reset
</button>
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Saving...</span>
</>
) : (
<span>Save</span>
)}
</button>
</div>
</>
);
}
@@ -0,0 +1,201 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { API_BASE_URL } from "@/lib/config";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
type Phase = "loading" | "bubble" | "form" | "thankyou" | "hidden";
export default function FeedbackBubble() {
const { isAuthenticated, user, getAccessToken } = useAuth();
const [phase, setPhase] = useState<Phase>("loading");
const [rating, setRating] = useState<number>(0);
const [hoveredStar, setHoveredStar] = useState<number>(0);
const [comment, setComment] = useState("");
const [submitting, setSubmitting] = useState(false);
// Check eligibility on mount
useEffect(() => {
if (!isAuthenticated || !user) return;
let cancelled = false;
const checkEligibility = async () => {
try {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/feedback/status`,
getAccessToken
);
if (cancelled) return;
if (response.ok) {
const data = await response.json();
if (data.eligible) {
// Show bubble after 3s delay
setTimeout(() => {
if (!cancelled) setPhase("bubble");
}, 3000);
} else {
setPhase("hidden");
}
} else {
setPhase("hidden");
}
} catch {
setPhase("hidden");
}
};
checkEligibility();
return () => { cancelled = true; };
}, [isAuthenticated, user, getAccessToken]);
const handleDismiss = useCallback(async () => {
setPhase("hidden");
try {
await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dismissed: true }),
});
} catch {
// Best effort
}
}, [getAccessToken]);
const handleSubmit = useCallback(async () => {
if (rating === 0 || submitting) return;
setSubmitting(true);
try {
await authenticatedFetch(`${API_BASE_URL}/api/feedback`, getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating, comment: comment.trim() || null, dismissed: false }),
});
setPhase("thankyou");
setTimeout(() => setPhase("hidden"), 2000);
} catch {
// Still show thank you — feedback may have been recorded
setPhase("thankyou");
setTimeout(() => setPhase("hidden"), 2000);
} finally {
setSubmitting(false);
}
}, [rating, comment, submitting, getAccessToken]);
// Don't render anything while loading or hidden
if (phase === "loading" || phase === "hidden") return null;
return (
<div className="fixed bottom-20 right-6 z-40 sm:bottom-20 sm:right-6">
{/* Collapsed Bubble */}
{phase === "bubble" && (
<div
className="flex items-center gap-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-full px-4 py-2.5 shadow-lg cursor-pointer hover:shadow-xl transition-all duration-300 animate-fade-in-up"
onClick={() => setPhase("form")}
>
<svg className="w-4 h-4 text-yellow-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">Feedback</span>
<button
onClick={(e) => { e.stopPropagation(); handleDismiss(); }}
className="ml-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
aria-label="Dismiss feedback"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
{/* Expanded Form */}
{phase === "form" && (
<div className="w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl animate-fade-in-up">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
How&apos;s your experience?
</h3>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
aria-label="Dismiss feedback"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-4 pb-4 space-y-3">
{/* Star Rating */}
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setRating(star)}
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(0)}
className="min-h-[44px] min-w-[44px] flex items-center justify-center transition-transform hover:scale-110"
aria-label={`Rate ${star} star${star > 1 ? "s" : ""}`}
>
<svg
className={`w-7 h-7 transition-colors ${
star <= (hoveredStar || rating)
? "text-yellow-400"
: "text-gray-300 dark:text-gray-600"
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
))}
</div>
{/* Comment */}
<div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value.slice(0, 500))}
placeholder="Any feedback? (optional)"
rows={2}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none placeholder-gray-400 dark:placeholder-gray-500"
/>
{comment.length > 0 && (
<p className="text-xs text-gray-400 text-right mt-0.5">{comment.length}/500</p>
)}
</div>
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={rating === 0 || submitting}
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? "Sending..." : "Send Feedback"}
</button>
</div>
</div>
)}
{/* Thank You */}
{phase === "thankyou" && (
<div className="flex items-center gap-2 bg-white dark:bg-gray-800 border border-green-200 dark:border-green-700 rounded-full px-4 py-2.5 shadow-lg animate-fade-in-up">
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
Thanks for your feedback!
</span>
</div>
)}
</div>
);
}
File diff suppressed because one or more lines are too long