Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions JobFlow.API/Controllers/EstimateController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ public async Task<IActionResult> GetPublicPdf(string token)
return File(result.Value, "application/pdf", $"estimate-{token}.pdf");
}

[HttpPost("first-win")]
[Authorize]
public async Task<IActionResult> CreateFirstWin()
{
var organizationId = HttpContext.GetOrganizationId();
var result = await estimateService.CreateFirstWinAsync(organizationId);
return result.IsSuccess ? Ok(result.Value) : ProblemFrom(result);
}

private ObjectResult ProblemFrom(Result result)
{
var problem = result.ToProblemDetails() as Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult;
Expand Down
10 changes: 10 additions & 0 deletions JobFlow.API/Controllers/OrganizationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,14 @@ private async Task CreateOwnerEmployeeAsync(OrganizationRegisterDto model, User
model.Id, user.Id);
}
}

[HttpPost]
[Route("milestones")]
[Authorize]
public async Task<IResult> MarkMilestone([FromBody] MarkMilestoneRequest request)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await _organizationService.MarkMilestoneAsync(organizationId, request.Milestone);
return result.IsSuccess ? Results.Ok() : result.ToProblemDetails();
}
}
60 changes: 60 additions & 0 deletions JobFlow.API/Controllers/SetupCompanionController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using JobFlow.API.Extensions;
using JobFlow.Business.Extensions;
using JobFlow.Business.Services.ServiceInterfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

namespace JobFlow.API.Controllers;

[ApiController]
[Route("api/setup-companion")]
[Authorize]
public class SetupCompanionController : ControllerBase
{
private readonly ISetupCompanionService _setupCompanionService;

public SetupCompanionController(ISetupCompanionService setupCompanionService)
{
_setupCompanionService = setupCompanionService;
}

[HttpPost("events")]
public async Task<IResult> TrackEvent([FromBody] TrackSetupCompanionEventRequest request)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await _setupCompanionService.TrackEventAsync(
organizationId,
request.SessionId,
request.QuestionKey,
request.AnswerKey);

return result.IsSuccess ? Results.Ok() : result.ToProblemDetails();
}

[HttpPost("ask")]
[EnableRateLimiting("companion-ask")]
public async Task<IResult> Ask([FromBody] AskSetupCompanionRequest request)
{
var organizationId = HttpContext.GetOrganizationId();
var result = await _setupCompanionService.AskAsync(
organizationId,
request.SessionId,
request.Question,
request.CurrentRoute);

return result.IsSuccess
? Results.Ok(new { answer = result.Value })
: result.ToProblemDetails();
}
}

public sealed record TrackSetupCompanionEventRequest(
string SessionId,
string QuestionKey,
string? AnswerKey);

public sealed record AskSetupCompanionRequest(
string SessionId,
string Question,
string CurrentRoute);
3 changes: 3 additions & 0 deletions JobFlow.API/Models/OrganizationDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace JobFlow.API.Models;

public sealed record MarkMilestoneRequest(string Milestone);
21 changes: 21 additions & 0 deletions JobFlow.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,21 @@ static JsonDocument ParseFirebaseAdminSdkJson(string json)
AutoReplenishment = true
});
});

// LLM-backed setup companion — 20 requests per hour per org to control OpenAI spend
options.AddPolicy("companion-ask", context =>
{
var orgId = context.User?.FindFirst("org_id")?.Value
?? context.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter($"companion:{orgId}", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromHours(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});

// ============================================================
Expand Down Expand Up @@ -521,6 +536,12 @@ static JsonDocument ParseFirebaseAdminSdkJson(string json)
builder.Services.AddSingleton<IBrevoSettings>(sp => sp.GetRequiredService<IOptions<BrevoSettings>>().Value);
builder.Services.AddSingleton<ISquareSettings>(sp => sp.GetRequiredService<IOptions<SquareSettings>>().Value);

builder.Services.Configure<OpenAiSettings>(options =>
{
options.ApiKey = builder.Configuration["OpenAI-ApiKey"] ?? "";
options.Model = builder.Configuration["OpenAI-Model"] ?? "gpt-4o-mini";
});

// ============================================================
// DEPENDENCY INJECTION, MAPPINGS, AUTHORIZATION
// ============================================================
Expand Down
7 changes: 7 additions & 0 deletions JobFlow.Business/ConfigurationSettings/OpenAiSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace JobFlow.Business.ConfigurationSettings;

public class OpenAiSettings
{
public string ApiKey { get; set; } = string.Empty;
public string Model { get; set; } = "gpt-4o-mini";
}
1 change: 1 addition & 0 deletions JobFlow.Business/JobFlow.Business.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenAI" Version="2.2.0" />
<PackageReference Include="Square" Version="43.0.0" />
<PackageReference Include="Stripe.net" Version="50.3.0" />
<PackageReference Include="Twilio" Version="7.14.7" />
Expand Down
2 changes: 2 additions & 0 deletions JobFlow.Business/Models/DTOs/OrganizationDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,6 @@ public class OrganizationDto
public bool IsSquareConnected { get; set; }
public bool PaymentSetupDeferred { get; set; }
public OrgSize OrgSize { get; set; }
public DateTimeOffset? FirstRealEstimateSentAt { get; set; }
public DateTimeOffset? ReferralCtaShownAt { get; set; }
}
65 changes: 65 additions & 0 deletions JobFlow.Business/Services/EstimateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,71 @@ private static string GeneratePublicToken()
return Convert.ToHexString(bytes).ToLowerInvariant();
}

public async Task<Result<EstimateDto>> CreateFirstWinAsync(Guid organizationId)
{
var org = await unitOfWork.RepositoryOf<Organization>()
.Query()
.FirstOrDefaultAsync(o => o.Id == organizationId);

if (org == null)
return Result.Failure<EstimateDto>(EstimateErrors.NotFound);

var ownerEmail = org.EmailAddress ?? string.Empty;

// Find or create a self-client record for the org owner
var selfClient = await clients.Query()
.FirstOrDefaultAsync(c => c.OrganizationId == organizationId && c.EmailAddress == ownerEmail);

if (selfClient == null)
{
selfClient = new OrganizationClient
{
OrganizationId = organizationId,
FirstName = org.ContactFirstName ?? "Business",
LastName = org.ContactLastName ?? "Owner",
EmailAddress = ownerEmail
};
await clients.AddAsync(selfClient);
await unitOfWork.SaveChangesAsync();
}

var estimateNumber = await _numberGenerator.GenerateAsync(organizationId);

var demoLineItems = new List<EstimateLineItem>
{
new() { Name = "Initial Consultation", Description = "On-site assessment and project evaluation", Quantity = 1m, UnitPrice = 150m, Total = 150m },
new() { Name = "Labor", Description = "Professional service — 2 hours", Quantity = 2m, UnitPrice = 75m, Total = 150m },
new() { Name = "Materials & Supplies", Description = "Standard supplies for the project", Quantity = 1m, UnitPrice = 85m, Total = 85m }
};

var estimate = new Estimate
{
OrganizationId = organizationId,
OrganizationClientId = selfClient.Id,
EstimateNumber = estimateNumber,
Title = "Sample Estimate — First Win",
Status = EstimateStatus.Sent,
PublicToken = GeneratePublicToken(),
PublicTokenExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
SentAt = DateTimeOffset.UtcNow,
LineItems = demoLineItems
};

RecalculateTotals(estimate);
await estimates.AddAsync(estimate);
await unitOfWork.SaveChangesAsync();

selfClient.Organization = org;
await notificationService.SendClientEstimateSentNotificationAsync(selfClient, estimate);

var full = await estimates.Query()
.Include(x => x.OrganizationClient)
.Include(x => x.LineItems)
.FirstOrDefaultAsync(x => x.Id == estimate.Id);

return Result<EstimateDto>.Success(ToDto(full!));
}

private static EstimateDto ToDto(Estimate e) =>
new(
e.Id,
Expand Down
24 changes: 24 additions & 0 deletions JobFlow.Business/Services/OrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,28 @@ public async Task<Result> SetOrgSizeAsync(Guid organizationId, string? orgSizeVa
await _unitOfWork.SaveChangesAsync();
return Result.Success();
}

public async Task<Result> MarkMilestoneAsync(Guid organizationId, string milestone)
{
var org = await _unitOfWork.RepositoryOf<Organization>().GetByIdAsync(organizationId);
if (org == null)
return Result.Failure(OrganizationErrors.OrganizationNotFound);

var now = DateTimeOffset.UtcNow;
switch (milestone.Trim().ToLowerInvariant())
{
case "firstrealestimatesentat":
org.FirstRealEstimateSentAt ??= now;
break;
case "referralctashownat":
org.ReferralCtaShownAt ??= now;
break;
default:
return Result.Failure(Error.Failure("Organization.Milestone", $"Unknown milestone: {milestone}"));
}

_unitOfWork.RepositoryOf<Organization>().Update(org);
await _unitOfWork.SaveChangesAsync();
return Result.Success();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ public interface IEstimateService

Task<Result<EstimateDto>> AcceptAsync(Guid id, Guid organizationId, Guid organizationClientId);
Task<Result<EstimateDto>> DeclineAsync(Guid id, Guid organizationId, Guid organizationClientId);
Task<Result<EstimateDto>> CreateFirstWinAsync(Guid organizationId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ public interface IOrganizationService
Task<Result> UpdateSubscriptionStateAsync(Guid organizationId, string? subscriptionStatus, string? subscriptionPlanName = null, DateTime? subscriptionExpiresAt = null);
Task<Result> DeleteOrganization(Guid organizationId);
Task<Result> SetOrgSizeAsync(Guid organizationId, string? orgSizeValue);
Task<Result> MarkMilestoneAsync(Guid organizationId, string milestone);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using JobFlow.Business;

namespace JobFlow.Business.Services.ServiceInterfaces;

public interface ISetupCompanionService
{
Task<Result> TrackEventAsync(Guid organizationId, string sessionId, string questionKey, string? answerKey);
Task<Result<string>> AskAsync(Guid organizationId, string sessionId, string question, string currentRoute);
}
117 changes: 117 additions & 0 deletions JobFlow.Business/Services/SetupCompanionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using JobFlow.Business.ConfigurationSettings;
using JobFlow.Business.DI;
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Domain;
using JobFlow.Domain.Models;
using Microsoft.Extensions.Options;
using OpenAI.Chat;

namespace JobFlow.Business.Services;

[ScopedService]
public class SetupCompanionService : ISetupCompanionService
{
private readonly IUnitOfWork _unitOfWork;
private readonly OpenAiSettings _openAiSettings;

public SetupCompanionService(IUnitOfWork unitOfWork, IOptions<OpenAiSettings> openAiOptions)
{
_unitOfWork = unitOfWork;
_openAiSettings = openAiOptions.Value;
}

public async Task<Result> TrackEventAsync(Guid organizationId, string sessionId, string questionKey, string? answerKey)
{
var ev = new SetupCompanionEvent
{
OrganizationId = organizationId,
SessionId = sessionId,
QuestionKey = questionKey,
AnswerKey = answerKey,
OccurredAt = DateTimeOffset.UtcNow
};

await _unitOfWork.RepositoryOf<SetupCompanionEvent>().AddAsync(ev);
await _unitOfWork.SaveChangesAsync();
return Result.Success();
}

public async Task<Result<string>> AskAsync(Guid organizationId, string sessionId, string question, string currentRoute)
{
if (string.IsNullOrWhiteSpace(_openAiSettings.ApiKey))
return Result.Failure<string>(Error.Failure("Companion.NotConfigured", "AI companion is not configured."));

var org = await _unitOfWork.RepositoryOf<Organization>().GetByIdAsync(organizationId);
if (org is null)
return Result.Failure<string>(Error.NotFound("Organization.NotFound", "Organization not found."));

// Track the free-text ask as an analytics event
var ev = new SetupCompanionEvent
{
OrganizationId = organizationId,
SessionId = sessionId,
QuestionKey = "free-text",
AnswerKey = null,
OccurredAt = DateTimeOffset.UtcNow
};
await _unitOfWork.RepositoryOf<SetupCompanionEvent>().AddAsync(ev);
await _unitOfWork.SaveChangesAsync();

var systemPrompt = BuildSystemPrompt(org, currentRoute);

var client = new ChatClient(_openAiSettings.Model, _openAiSettings.ApiKey);

var messages = new List<ChatMessage>
{
new SystemChatMessage(systemPrompt),
new UserChatMessage(question)
};

var response = await client.CompleteChatAsync(messages, new ChatCompletionOptions
{
MaxOutputTokenCount = 400,
Temperature = 0.3f
});

var answer = response.Value.Content[0].Text;
return Result.Success(answer);
}

private static string BuildSystemPrompt(Organization org, string currentRoute)
{
var onboardingStatus = org.OnBoardingComplete
? "Onboarding complete."
: "Onboarding not yet complete.";

var paymentStatus = org.CanAcceptPayments
? "Payment processing is connected."
: org.PaymentSetupDeferred
? "Payment setup is deferred — not yet connected."
: "Payment processing is not connected.";

var industry = string.IsNullOrWhiteSpace(org.IndustryKey) ? "general" : org.IndustryKey;
var plan = string.IsNullOrWhiteSpace(org.SubscriptionPlanName) ? "Go" : org.SubscriptionPlanName;

return $"""
You are the JobFlow Setup Companion — a friendly, concise assistant embedded inside the JobFlow app.
JobFlow is a field service management platform for small businesses (contractors, cleaning companies, landscapers, IT services, HVAC, and similar trades).

Your sole purpose is to help users understand and set up JobFlow. You answer ONLY questions about:
- How JobFlow features work (jobs, estimates, invoices, scheduling, clients, employees, pricebook, payments, branding)
- What the user should do next in their setup
- Whether a step is required or optional for them

If asked anything unrelated to JobFlow, politely redirect: "I can only help with JobFlow setup questions."

Current user context:
- Industry: {industry}
- Plan: {plan}
- {onboardingStatus}
- {paymentStatus}
- Current page in the app: {currentRoute}

Respond in plain, friendly language. Keep answers under 100 words. Use bullet points only when listing steps.
Never fabricate features that don't exist in JobFlow.
""";
}
}
Loading
Loading