diff --git a/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs b/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs index 3ce67aa..f405305 100644 --- a/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs @@ -290,14 +290,15 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild ISender sender, CancellationToken ct) => { - await sender.Send(new TriggerCommitSyncCommand(projectId), ct); - return Results.Accepted($"/api/v1/projects/{projectId}/commits"); + // Synchronous fetch+store so the client can refresh the commit list right away. + var newCommits = await sender.Send(new TriggerCommitSyncCommand(projectId), ct); + return Results.Ok(new { newCommits }); }) // Membership (owner OR accepted member) is enforced in the handler, so the // freelancer who actually pushes the commits can refresh them too. .RequireAuthorization() .WithName("TriggerCommitSync") - .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) @@ -349,13 +350,14 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild ISender sender, CancellationToken ct) => { + // Generated synchronously — returns the full summary (content + generatedAt). var dto = await sender.Send(new GenerateProjectSummaryCommand(projectId), ct); - return Results.Accepted(value: dto); + return Results.Ok(dto); }) .RequireAuthorization() .RequireRateLimiting("ai") .WithName("GenerateProjectSummary") - .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) diff --git a/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommand.cs b/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommand.cs index cb07204..8a0dad3 100644 --- a/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommand.cs +++ b/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommand.cs @@ -3,4 +3,4 @@ namespace Loopless.Application.Features.Projects.GenerateSummary; -public sealed record GenerateProjectSummaryCommand(Guid ProjectId) : IRequest; +public sealed record GenerateProjectSummaryCommand(Guid ProjectId) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommandHandler.cs b/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommandHandler.cs index 33c71f0..e2b4c2a 100644 --- a/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/GenerateSummary/GenerateProjectSummaryCommandHandler.cs @@ -11,9 +11,11 @@ namespace Loopless.Application.Features.Projects.GenerateSummary; public sealed class GenerateProjectSummaryCommandHandler( IAppDbContext db, ICurrentUser currentUser, - ISummaryJobEnqueuer jobEnqueuer) : IRequestHandler + IAiSummaryService aiSummary) : IRequestHandler { - public async Task Handle(GenerateProjectSummaryCommand request, CancellationToken cancellationToken) + private const string Model = "gpt-4o-mini"; + + public async Task Handle(GenerateProjectSummaryCommand request, CancellationToken cancellationToken) { var keycloakId = currentUser.KeycloakId ?? throw new UnauthorizedException("Missing subject claim."); @@ -37,20 +39,58 @@ public async Task Handle(GenerateProjectSummaryCommand requ if (!isOwner && !isMember) throw new ForbiddenException("Only project members can generate summaries."); + // Generate synchronously so the response carries the real content + timestamp + // (the client renders "Generated " straight from this DTO). var summary = new ProjectSummary { ProjectId = project.Id, - Status = SummaryStatus.Queued, + Status = SummaryStatus.Running, }; - db.ProjectSummaries.Add(summary); await db.SaveChangesAsync(cancellationToken); - var jobId = jobEnqueuer.Enqueue(project.Id, summary.Id); + try + { + var commitMessages = await db.GitHubCommits + .Where(c => c.ProjectId == project.Id) + .OrderByDescending(c => c.CommittedAt) + .Take(50) + .Select(c => $"{c.Author}: {c.Message}") + .ToListAsync(cancellationToken); - summary.HangfireJobId = jobId; - await db.SaveChangesAsync(cancellationToken); + var standupEntries = await db.Standups + .Where(s => s.ProjectId == project.Id) + .OrderByDescending(s => s.CreatedAt) + .Take(10) + .Join( + db.Users, + s => s.FreelancerId, + u => u.Id, + (s, u) => $"[{u.Name}] Did: {s.WhatDid} | Plan: {s.WhatPlan} | Blockers: {s.Blockers}") + .ToListAsync(cancellationToken); - return new SummaryJobStatusDto(jobId, "queued"); + var content = await aiSummary.GenerateSummaryAsync( + project.Title, + project.Description, + commitMessages, + standupEntries, + cancellationToken); + + summary.Content = content; + summary.ModelUsed = Model; + summary.GeneratedAt = DateTimeOffset.UtcNow; + summary.Status = SummaryStatus.Completed; + await db.SaveChangesAsync(cancellationToken); + + return new ProjectSummaryDto( + summary.Id, summary.ProjectId, summary.Content!, summary.ModelUsed!, summary.GeneratedAt); + } + catch (Exception ex) + { + summary.Status = SummaryStatus.Failed; + summary.FailureReason = ex.Message; + await db.SaveChangesAsync(CancellationToken.None); + throw; + } } } diff --git a/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs b/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs index 0e0cf12..b73dc18 100644 --- a/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/GetCommits/GetProjectCommitsQueryHandler.cs @@ -35,7 +35,14 @@ public async Task> Handle(GetProjectCommitsQuery re if (!isOwner && !isMember) throw new ForbiddenException("Only project members can view commits."); - var baseQuery = db.GitHubCommits.Where(c => c.ProjectId == request.ProjectId); + // Only show commits whose git author email matches a project member's account + // email (owner + accepted invitation participants). Commits by non-members are hidden. + var memberEmails = await ProjectMembers.GetMemberEmailsAsync(db, project.Id, project.OwnerId, cancellationToken); + + var baseQuery = db.GitHubCommits.Where(c => + c.ProjectId == request.ProjectId + && c.AuthorEmail != null + && memberEmails.Contains(c.AuthorEmail.ToLower())); var totalCount = await baseQuery.CountAsync(cancellationToken); var items = await baseQuery diff --git a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs index 25d1c52..0c1f752 100644 --- a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs @@ -68,9 +68,17 @@ public async Task Handle(GetProjectDashboardQuery request, .ToListAsync(cancellationToken) : new List(); + // Only surface commits whose git author email matches a project member (same rule + // as the Commits tab — see GetProjectCommitsQueryHandler). + var memberEmails = hasFullAccess + ? await ProjectMembers.GetMemberEmailsAsync(db, project.Id, project.OwnerId, cancellationToken) + : new List(); + var recentCommits = hasFullAccess ? await db.GitHubCommits - .Where(c => c.ProjectId == project.Id) + .Where(c => c.ProjectId == project.Id + && c.AuthorEmail != null + && memberEmails.Contains(c.AuthorEmail.ToLower())) .OrderByDescending(c => c.CommittedAt) .Take(10) .Select(c => new GitHubCommitDto( diff --git a/backend/src/Loopless.Application/Features/Projects/ProjectMembers.cs b/backend/src/Loopless.Application/Features/Projects/ProjectMembers.cs new file mode 100644 index 0000000..bb45645 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Projects/ProjectMembers.cs @@ -0,0 +1,36 @@ +using Loopless.Application.Interfaces; +using Loopless.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Features.Projects; + +internal static class ProjectMembers +{ + /// + /// Lower-cased account emails of everyone on the project's team — the owner plus the + /// users in accepted invitations (enterprise + freelancer sides). Used to match a + /// GitHub commit's author email to a member so non-member commits can be hidden. + /// + public static async Task> GetMemberEmailsAsync( + IAppDbContext db, Guid projectId, Guid ownerId, CancellationToken cancellationToken) + { + var invited = await db.ProjectInvitations + .Where(i => i.ProjectId == projectId && i.Status == InvitationStatus.Accepted) + .Select(i => new { i.EnterpriseId, i.FreelancerId }) + .ToListAsync(cancellationToken); + + var memberIds = new HashSet { ownerId }; + foreach (var inv in invited) + { + memberIds.Add(inv.EnterpriseId); + if (inv.FreelancerId.HasValue) + memberIds.Add(inv.FreelancerId.Value); + } + + var ids = memberIds.ToList(); + return await db.Users + .Where(u => ids.Contains(u.Id)) + .Select(u => u.Email.ToLower()) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommand.cs b/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommand.cs index 48db924..1090991 100644 --- a/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommand.cs +++ b/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommand.cs @@ -2,4 +2,5 @@ namespace Loopless.Application.Features.Projects.SyncCommits; -public sealed record TriggerCommitSyncCommand(Guid ProjectId) : IRequest; +// Returns the number of newly stored commits. +public sealed record TriggerCommitSyncCommand(Guid ProjectId) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommandHandler.cs b/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommandHandler.cs index 3d1e1ff..6dd0c6f 100644 --- a/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/SyncCommits/TriggerCommitSyncCommandHandler.cs @@ -9,9 +9,9 @@ namespace Loopless.Application.Features.Projects.SyncCommits; public sealed class TriggerCommitSyncCommandHandler( IAppDbContext db, ICurrentUser currentUser, - ICommitSyncEnqueuer enqueuer) : IRequestHandler + ICommitSyncService commitSync) : IRequestHandler { - public async Task Handle(TriggerCommitSyncCommand request, CancellationToken cancellationToken) + public async Task Handle(TriggerCommitSyncCommand request, CancellationToken cancellationToken) { var keycloakId = currentUser.KeycloakId ?? throw new UnauthorizedException("Missing subject claim."); @@ -39,7 +39,8 @@ public async Task Handle(TriggerCommitSyncCommand request, CancellationTok if (string.IsNullOrWhiteSpace(project.GitHubRepoUrl)) throw new ConflictException("Project has no GitHub repository linked."); - enqueuer.EnqueueProjectSync(project.Id); - return Unit.Value; + // Fetch + store synchronously so the caller can refresh the commit list immediately. + // forceFull backfills author emails on commits stored before that column existed. + return await commitSync.SyncProjectAsync(project.Id, forceFull: true, cancellationToken); } } diff --git a/backend/src/Loopless.Application/Interfaces/ICommitSyncService.cs b/backend/src/Loopless.Application/Interfaces/ICommitSyncService.cs new file mode 100644 index 0000000..658c121 --- /dev/null +++ b/backend/src/Loopless.Application/Interfaces/ICommitSyncService.cs @@ -0,0 +1,17 @@ +namespace Loopless.Application.Interfaces; + +/// +/// Fetches commits from a project's linked GitHub repo and stores them. Shared by the +/// on-demand "Sync now" path (synchronous, forceFull: true) and the recurring +/// background job (incremental, forceFull: false). +/// +public interface ICommitSyncService +{ + /// + /// Syncs commits for the project. When is true the full + /// recent page is fetched (ignoring the last-synced timestamp) so author emails are + /// backfilled on commits stored before that column existed. Returns the number of + /// newly stored commits. + /// + Task SyncProjectAsync(Guid projectId, bool forceFull, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Loopless.Application/Interfaces/IGitHubService.cs b/backend/src/Loopless.Application/Interfaces/IGitHubService.cs index e39d91a..86b430f 100644 --- a/backend/src/Loopless.Application/Interfaces/IGitHubService.cs +++ b/backend/src/Loopless.Application/Interfaces/IGitHubService.cs @@ -12,4 +12,5 @@ public sealed record GitHubCommitPayload( string Sha, string Message, string Author, + string? AuthorEmail, DateTimeOffset CommittedAt); diff --git a/backend/src/Loopless.Domain/Entities/GitHubCommit.cs b/backend/src/Loopless.Domain/Entities/GitHubCommit.cs index 6608679..2004030 100644 --- a/backend/src/Loopless.Domain/Entities/GitHubCommit.cs +++ b/backend/src/Loopless.Domain/Entities/GitHubCommit.cs @@ -8,6 +8,12 @@ public class GitHubCommit : BaseEntity public required string Sha { get; set; } public required string Message { get; set; } public required string Author { get; set; } + + // Git author email — used to match a commit to a project member (see + // GetProjectCommitsQueryHandler). Nullable: commits synced before this column + // existed have null until the next sync backfills it. + public string? AuthorEmail { get; set; } + public required DateTimeOffset CommittedAt { get; set; } public DateTimeOffset SyncedAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/backend/src/Loopless.Infrastructure/BackgroundJobs/GitHubSyncJob.cs b/backend/src/Loopless.Infrastructure/BackgroundJobs/GitHubSyncJob.cs index 1c6e990..f236914 100644 --- a/backend/src/Loopless.Infrastructure/BackgroundJobs/GitHubSyncJob.cs +++ b/backend/src/Loopless.Infrastructure/BackgroundJobs/GitHubSyncJob.cs @@ -1,7 +1,5 @@ -using Hangfire; +using Hangfire; using Loopless.Application.Interfaces; -using Loopless.Domain.Entities; -using Loopless.Domain.Enums; using Loopless.Infrastructure.GitHub; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -13,70 +11,50 @@ public sealed class GitHubSyncJob public const string RecurringJobId = "github-commit-sync"; private readonly IAppDbContext _db; - private readonly IGitHubService _github; + private readonly ICommitSyncService _commitSync; private readonly IBackgroundJobClient _jobClient; private readonly ILogger _logger; public GitHubSyncJob( IAppDbContext db, - IGitHubService github, + ICommitSyncService commitSync, IBackgroundJobClient jobClient, ILogger logger) { _db = db; - _github = github; + _commitSync = commitSync; _jobClient = jobClient; _logger = logger; } public async Task SyncAllAsync(CancellationToken cancellationToken = default) { - var projects = await _db.Projects + var projectIds = await _db.Projects .Where(p => p.GitHubRepoUrl != null && p.GitHubRepoUrl != string.Empty) - .Select(p => new { p.Id, p.GitHubRepoUrl }) + .Select(p => p.Id) .ToListAsync(cancellationToken); - _logger.LogInformation("GitHubSyncJob: {Count} projects to sync", projects.Count); + _logger.LogInformation("GitHubSyncJob: {Count} projects to sync", projectIds.Count); - foreach (var project in projects) + foreach (var projectId in projectIds) { try { - await SyncProjectAsync(project.Id, cancellationToken); - } - catch (GitHubRateLimitException ex) - { - _logger.LogWarning(ex, "GitHubSyncJob: rate limit hit for project {ProjectId}, rescheduling in 30 minutes", project.Id); - _jobClient.Schedule( - job => job.SyncProjectAsync(project.Id, CancellationToken.None), - TimeSpan.FromMinutes(30)); + await SyncProjectAsync(projectId, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "GitHubSyncJob failed for project {ProjectId}", project.Id); + _logger.LogError(ex, "GitHubSyncJob failed for project {ProjectId}", projectId); } } } public async Task SyncProjectAsync(Guid projectId, CancellationToken cancellationToken = default) { - var project = await _db.Projects - .FirstOrDefaultAsync(p => p.Id == projectId, cancellationToken); - - if (project is null || string.IsNullOrWhiteSpace(project.GitHubRepoUrl)) - { - _logger.LogInformation("GitHubSyncJob: project {ProjectId} has no repo url - skipping", projectId); - return; - } - - var since = await _db.GitHubCommits - .Where(c => c.ProjectId == projectId) - .MaxAsync(c => (DateTimeOffset?)c.CommittedAt, cancellationToken); - - IReadOnlyList payloads; try { - payloads = await _github.FetchCommitsAsync(project.GitHubRepoUrl, since, cancellationToken); + // Background sync stays incremental; the on-demand "Sync now" path forces a full fetch. + await _commitSync.SyncProjectAsync(projectId, forceFull: false, cancellationToken); } catch (GitHubRateLimitException ex) { @@ -84,61 +62,6 @@ public async Task SyncProjectAsync(Guid projectId, CancellationToken cancellatio _jobClient.Schedule( job => job.SyncProjectAsync(projectId, CancellationToken.None), TimeSpan.FromMinutes(30)); - return; - } - - if (payloads.Count == 0) - { - _logger.LogInformation("GitHubSyncJob: no new commits for {ProjectId}", projectId); - return; } - - var incomingShas = payloads.Select(p => p.Sha).ToList(); - var existing = await _db.GitHubCommits - .Where(c => c.ProjectId == projectId && incomingShas.Contains(c.Sha)) - .Select(c => c.Sha) - .ToListAsync(cancellationToken); - - var existingSet = existing.ToHashSet(); - var newCommits = payloads - .Where(p => !existingSet.Contains(p.Sha)) - .Select(p => new GitHubCommit - { - ProjectId = projectId, - Sha = p.Sha, - Message = p.Message, - Author = p.Author, - CommittedAt = p.CommittedAt, - SyncedAt = DateTimeOffset.UtcNow, - }) - .ToList(); - - if (newCommits.Count == 0) - { - _logger.LogInformation("GitHubSyncJob: all {Count} incoming commits already stored for {ProjectId}", payloads.Count, projectId); - return; - } - - _db.GitHubCommits.AddRange(newCommits); - await _db.SaveChangesAsync(cancellationToken); - - _logger.LogInformation("GitHubSyncJob: stored {Count} new commits for {ProjectId}", newCommits.Count, projectId); - - // Auto-sync also triggers an AI summary. Create the ProjectSummary row first so the job - // can resolve it by id (mirrors GenerateProjectSummaryCommandHandler); Hangfire injects - // the real PerformContext + cancellation token at run time (null!/None are placeholders). - var summary = new ProjectSummary - { - ProjectId = projectId, - Status = SummaryStatus.Queued, - }; - _db.ProjectSummaries.Add(summary); - await _db.SaveChangesAsync(cancellationToken); - - var jobId = _jobClient.Enqueue( - job => job.GenerateAsync(projectId, summary.Id, null!, CancellationToken.None)); - - summary.HangfireJobId = jobId; - await _db.SaveChangesAsync(cancellationToken); } } diff --git a/backend/src/Loopless.Infrastructure/DependencyInjection.cs b/backend/src/Loopless.Infrastructure/DependencyInjection.cs index ac6d6d8..118fa82 100644 --- a/backend/src/Loopless.Infrastructure/DependencyInjection.cs +++ b/backend/src/Loopless.Infrastructure/DependencyInjection.cs @@ -56,6 +56,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.Configure(configuration.GetSection(GitHubOptions.SectionName)); services.AddHttpClient() .AddHttpMessageHandler(); + services.AddScoped(); services.Configure(configuration.GetSection(StorageOptions.SectionName)); services.AddSingleton(); diff --git a/backend/src/Loopless.Infrastructure/GitHub/CommitSyncService.cs b/backend/src/Loopless.Infrastructure/GitHub/CommitSyncService.cs new file mode 100644 index 0000000..99f4302 --- /dev/null +++ b/backend/src/Loopless.Infrastructure/GitHub/CommitSyncService.cs @@ -0,0 +1,110 @@ +using Hangfire; +using Loopless.Application.Interfaces; +using Loopless.Domain.Entities; +using Loopless.Domain.Enums; +using Loopless.Infrastructure.BackgroundJobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Loopless.Infrastructure.GitHub; + +internal sealed class CommitSyncService( + IAppDbContext db, + IGitHubService github, + IBackgroundJobClient jobClient, + ILogger logger) : ICommitSyncService +{ + public async Task SyncProjectAsync(Guid projectId, bool forceFull, CancellationToken cancellationToken = default) + { + var project = await db.Projects + .FirstOrDefaultAsync(p => p.Id == projectId, cancellationToken); + + if (project is null || string.IsNullOrWhiteSpace(project.GitHubRepoUrl)) + { + logger.LogInformation("CommitSync: project {ProjectId} has no repo url - skipping", projectId); + return 0; + } + + // On-demand sync fetches the full recent page (since = null) so AuthorEmail is + // backfilled on already-stored commits; the recurring job stays incremental. + DateTimeOffset? since = null; + if (!forceFull) + { + since = await db.GitHubCommits + .Where(c => c.ProjectId == projectId) + .MaxAsync(c => (DateTimeOffset?)c.CommittedAt, cancellationToken); + } + + // GitHubRateLimitException is intentionally not caught here — the recurring job + // reschedules on rate limit; the on-demand handler surfaces it to the caller. + var payloads = await github.FetchCommitsAsync(project.GitHubRepoUrl, since, cancellationToken); + + if (payloads.Count == 0) + { + logger.LogInformation("CommitSync: no commits returned for {ProjectId}", projectId); + return 0; + } + + var incomingShas = payloads.Select(p => p.Sha).ToList(); + var existing = await db.GitHubCommits + .Where(c => c.ProjectId == projectId && incomingShas.Contains(c.Sha)) + .ToListAsync(cancellationToken); + var existingBySha = existing.ToDictionary(c => c.Sha); + + var newCommits = new List(); + foreach (var payload in payloads) + { + if (existingBySha.TryGetValue(payload.Sha, out var row)) + { + // Backfill the author email on commits stored before we captured it. + if (string.IsNullOrWhiteSpace(row.AuthorEmail) && !string.IsNullOrWhiteSpace(payload.AuthorEmail)) + row.AuthorEmail = payload.AuthorEmail; + continue; + } + + newCommits.Add(new GitHubCommit + { + ProjectId = projectId, + Sha = payload.Sha, + Message = payload.Message, + Author = payload.Author, + AuthorEmail = payload.AuthorEmail, + CommittedAt = payload.CommittedAt, + SyncedAt = DateTimeOffset.UtcNow, + }); + } + + if (newCommits.Count > 0) + db.GitHubCommits.AddRange(newCommits); + + await db.SaveChangesAsync(cancellationToken); + + if (newCommits.Count == 0) + { + logger.LogInformation( + "CommitSync: no new commits for {ProjectId} (refreshed {Count} existing)", projectId, existing.Count); + return 0; + } + + logger.LogInformation("CommitSync: stored {Count} new commits for {ProjectId}", newCommits.Count, projectId); + + // New commits landed -> refresh the AI summary in the background. Create the + // ProjectSummary row first so the job can resolve it by id; Hangfire injects the + // real PerformContext + cancellation token at run time (null!/None are placeholders). + var summary = new ProjectSummary + { + ProjectId = projectId, + Status = SummaryStatus.Queued, + }; + db.ProjectSummaries.Add(summary); + await db.SaveChangesAsync(cancellationToken); + + var jobId = jobClient.Enqueue( + job => job.GenerateAsync(projectId, summary.Id, null!, CancellationToken.None)); + + summary.HangfireJobId = jobId; + await db.SaveChangesAsync(cancellationToken); + + return newCommits.Count; + } +} diff --git a/backend/src/Loopless.Infrastructure/GitHub/GitHubService.cs b/backend/src/Loopless.Infrastructure/GitHub/GitHubService.cs index 2aab641..6af3daa 100644 --- a/backend/src/Loopless.Infrastructure/GitHub/GitHubService.cs +++ b/backend/src/Loopless.Infrastructure/GitHub/GitHubService.cs @@ -106,6 +106,7 @@ public async Task> FetchCommitsAsync( i.Sha!, Truncate(i.Commit!.Message ?? string.Empty, 2000), Truncate(i.Commit!.Author?.Name ?? "unknown", 200), + i.Commit!.Author?.Email is { Length: > 0 } email ? Truncate(email, 320) : null, i.Commit!.Author?.Date ?? DateTimeOffset.UtcNow)) .ToList(); } @@ -140,5 +141,6 @@ private sealed record GitHubCommitDetail( private sealed record GitHubAuthor( [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("email")] string? Email, [property: JsonPropertyName("date")] DateTimeOffset? Date); } diff --git a/backend/src/Loopless.Infrastructure/Persistence/Configurations/GitHubCommitConfiguration.cs b/backend/src/Loopless.Infrastructure/Persistence/Configurations/GitHubCommitConfiguration.cs index 09599d2..d118419 100644 --- a/backend/src/Loopless.Infrastructure/Persistence/Configurations/GitHubCommitConfiguration.cs +++ b/backend/src/Loopless.Infrastructure/Persistence/Configurations/GitHubCommitConfiguration.cs @@ -16,6 +16,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(c => c.Sha).HasMaxLength(64).IsRequired(); builder.Property(c => c.Message).HasMaxLength(2000).IsRequired(); builder.Property(c => c.Author).HasMaxLength(200).IsRequired(); + builder.Property(c => c.AuthorEmail).HasMaxLength(320); builder.Property(c => c.CommittedAt).IsRequired(); builder.Property(c => c.SyncedAt).IsRequired(); diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613134653_AddCommitAuthorEmail.Designer.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613134653_AddCommitAuthorEmail.Designer.cs new file mode 100644 index 0000000..ad21c82 --- /dev/null +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613134653_AddCommitAuthorEmail.Designer.cs @@ -0,0 +1,1495 @@ +// +using System; +using System.Collections.Generic; +using Loopless.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace Loopless.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260613134653_AddCommitAuthorEmail")] + partial class AddCommitAuthorEmail + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Loopless.Domain.Entities.AuditTrail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("EntityId"); + + b.HasIndex("OccurredAt"); + + b.ToTable("audit_trails", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ContentFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedById") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Pending"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .IsDescending(); + + b.HasIndex("EntityId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("ReviewedById"); + + b.HasIndex("Status"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("content_flags", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastMessageAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Participant1Id") + .HasColumnType("uuid"); + + b.Property("Participant2Id") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("LastMessageAt") + .IsDescending(); + + b.HasIndex("Participant1Id"); + + b.HasIndex("Participant2Id"); + + b.HasIndex("Participant1Id", "Participant2Id") + .IsUnique(); + + b.ToTable("conversations", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Embedding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vector") + .IsRequired() + .HasColumnType("vector(1536)"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId") + .IsUnique(); + + b.ToTable("embeddings", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.EnterpriseProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanySize") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Industry") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("TypicalNeeds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("enterprise_profiles", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.FreelancerProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Availability") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("GithubUsername") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HourlyRate") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("LinkedinUrl") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .HasColumnType("text"); + + b.Property("Proficiencies") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("freelancer_profiles", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.GitHubCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AuthorEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("CommittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Sha") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "CommittedAt") + .IsDescending(false, true); + + b.HasIndex("ProjectId", "Sha") + .IsUnique(); + + b.ToTable("github_commits", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailNotificationSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("ConversationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionUrl") + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("GitHubRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GitHubWebhookSecret") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("FreelancerId"); + + b.PrimitiveCollection>("RequiredSkills") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("StandupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("StandupRemindersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId") + .HasDatabaseName("ix_projects_freelancer_id"); + + b.HasIndex("Status"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "UploadedAt") + .IsDescending(false, true); + + b.ToTable("project_files", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnterpriseId") + .HasColumnType("uuid"); + + b.Property("FreelancerId") + .HasColumnType("uuid"); + + b.Property("InitiatedBy") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("InvitedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RemovedReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EnterpriseId"); + + b.HasIndex("FreelancerId"); + + b.HasIndex("Status"); + + b.HasIndex("ProjectId", "EnterpriseId"); + + b.HasIndex("ProjectId", "FreelancerId"); + + b.ToTable("project_invitations", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("project_links", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectSummary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureReason") + .HasColumnType("text"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HangfireJobId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ModelUsed") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "GeneratedAt") + .IsDescending(false, true); + + b.ToTable("project_summaries", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Status"); + + b.ToTable("project_tasks", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTaskAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangedByUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NewStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OldStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAt"); + + b.HasIndex("ChangedByUserId"); + + b.HasIndex("TaskId"); + + b.ToTable("project_task_audit_logs", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Skill", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.ToTable("skills", (string)null); + + b.HasData( + new + { + Id = "react", + Category = "Tech", + Label = "React" + }, + new + { + Id = "vue", + Category = "Tech", + Label = "Vue" + }, + new + { + Id = "typescript", + Category = "Tech", + Label = "TypeScript" + }, + new + { + Id = "nextjs", + Category = "Tech", + Label = "Next.js" + }, + new + { + Id = "nodejs", + Category = "Tech", + Label = "Node.js" + }, + new + { + Id = "dotnet", + Category = "Tech", + Label = ".NET" + }, + new + { + Id = "postgresql", + Category = "Tech", + Label = "PostgreSQL" + }, + new + { + Id = "docker", + Category = "Tech", + Label = "Docker" + }, + new + { + Id = "kubernetes", + Category = "Tech", + Label = "Kubernetes" + }, + new + { + Id = "terraform", + Category = "Tech", + Label = "Terraform" + }, + new + { + Id = "python", + Category = "Tech", + Label = "Python" + }, + new + { + Id = "ml", + Category = "Tech", + Label = "Machine Learning" + }, + new + { + Id = "figma", + Category = "Design", + Label = "Figma" + }, + new + { + Id = "ui-design", + Category = "Design", + Label = "UI Design" + }, + new + { + Id = "ux-research", + Category = "Design", + Label = "UX Research" + }, + new + { + Id = "branding", + Category = "Design", + Label = "Branding" + }, + new + { + Id = "illustration", + Category = "Design", + Label = "Illustration" + }, + new + { + Id = "motion-design", + Category = "Design", + Label = "Motion Design" + }, + new + { + Id = "copywriting", + Category = "Writing & Content", + Label = "Copywriting" + }, + new + { + Id = "content-writing", + Category = "Writing & Content", + Label = "Content Writing" + }, + new + { + Id = "seo", + Category = "Writing & Content", + Label = "SEO" + }, + new + { + Id = "technical-writing", + Category = "Writing & Content", + Label = "Technical Writing" + }, + new + { + Id = "ghostwriting", + Category = "Writing & Content", + Label = "Ghostwriting" + }, + new + { + Id = "proofreading", + Category = "Writing & Content", + Label = "Proofreading" + }, + new + { + Id = "social-media", + Category = "Marketing", + Label = "Social Media" + }, + new + { + Id = "email-marketing", + Category = "Marketing", + Label = "Email Marketing" + }, + new + { + Id = "paid-ads", + Category = "Marketing", + Label = "Paid Ads" + }, + new + { + Id = "growth-hacking", + Category = "Marketing", + Label = "Growth Hacking" + }, + new + { + Id = "analytics", + Category = "Marketing", + Label = "Analytics" + }, + new + { + Id = "video-editing", + Category = "Video & Audio", + Label = "Video Editing" + }, + new + { + Id = "animation", + Category = "Video & Audio", + Label = "Animation" + }, + new + { + Id = "podcast-editing", + Category = "Video & Audio", + Label = "Podcast Editing" + }, + new + { + Id = "voice-over", + Category = "Video & Audio", + Label = "Voice Over" + }, + new + { + Id = "project-management", + Category = "Business", + Label = "Project Management" + }, + new + { + Id = "consulting", + Category = "Business", + Label = "Consulting" + }, + new + { + Id = "accounting", + Category = "Business", + Label = "Accounting" + }, + new + { + Id = "legal", + Category = "Business", + Label = "Legal" + }, + new + { + Id = "translation", + Category = "Business", + Label = "Translation" + }, + new + { + Id = "data-entry", + Category = "Business", + Label = "Data Entry" + }, + new + { + Id = "marketing", + Category = "Enterprise", + Label = "Marketing" + }, + new + { + Id = "development", + Category = "Enterprise", + Label = "Development" + }, + new + { + Id = "cybersecurity", + Category = "Enterprise", + Label = "Cybersecurity" + }, + new + { + Id = "design", + Category = "Enterprise", + Label = "Design" + }, + new + { + Id = "creative", + Category = "Enterprise", + Label = "Creative" + }, + new + { + Id = "data-analytics", + Category = "Enterprise", + Label = "Data & Analytics" + }, + new + { + Id = "product-management", + Category = "Enterprise", + Label = "Product Management" + }, + new + { + Id = "customer-support", + Category = "Enterprise", + Label = "Customer Support" + }); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Standup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockerSentiment") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Blockers") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FreelancerId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WhatDid") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("WhatPlan") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("FreelancerId"); + + b.HasIndex("ProjectId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("standups", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("Normal"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Open"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("CreatedAt") + .IsDescending(); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("support_tickets", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EmailNotificationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSuspended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("KeycloakId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("KeycloakId") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.AuditTrail", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ContentFlag", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Reporter") + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "ReviewedBy") + .WithMany() + .HasForeignKey("ReviewedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Reporter"); + + b.Navigation("ReviewedBy"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Conversation", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Participant1") + .WithMany() + .HasForeignKey("Participant1Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "Participant2") + .WithMany() + .HasForeignKey("Participant2Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Participant1"); + + b.Navigation("Participant2"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.EnterpriseProfile", b => + { + b.HasOne("Loopless.Domain.Entities.User", "User") + .WithOne("EnterpriseProfile") + .HasForeignKey("Loopless.Domain.Entities.EnterpriseProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.FreelancerProfile", b => + { + b.HasOne("Loopless.Domain.Entities.User", "User") + .WithOne("FreelancerProfile") + .HasForeignKey("Loopless.Domain.Entities.FreelancerProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.GitHubCommit", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Message", b => + { + b.HasOne("Loopless.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Notification", b => + { + b.HasOne("Loopless.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Project", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectFile", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectInvitation", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Enterprise") + .WithMany() + .HasForeignKey("EnterpriseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "Freelancer") + .WithMany() + .HasForeignKey("FreelancerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany("Invitations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Enterprise"); + + b.Navigation("Freelancer"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectLink", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany("Links") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectSummary", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTask", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignee"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTaskAuditLog", b => + { + b.HasOne("Loopless.Domain.Entities.User", "ChangedByUser") + .WithMany() + .HasForeignKey("ChangedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.ProjectTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChangedByUser"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Standup", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Freelancer") + .WithMany() + .HasForeignKey("FreelancerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Freelancer"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.SupportTicket", b => + { + b.HasOne("Loopless.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Loopless.Domain.Entities.User", "Reporter") + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedAdmin"); + + b.Navigation("Reporter"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Project", b => + { + b.Navigation("Invitations"); + + b.Navigation("Links"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.User", b => + { + b.Navigation("EnterpriseProfile"); + + b.Navigation("FreelancerProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613134653_AddCommitAuthorEmail.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613134653_AddCommitAuthorEmail.cs new file mode 100644 index 0000000..6434cf7 --- /dev/null +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613134653_AddCommitAuthorEmail.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Loopless.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCommitAuthorEmail : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AuthorEmail", + table: "github_commits", + type: "character varying(320)", + maxLength: 320, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AuthorEmail", + table: "github_commits"); + } + } +} diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 0ef0241..b037c03 100644 --- a/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -288,6 +288,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("AuthorEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + b.Property("CommittedAt") .HasColumnType("timestamp with time zone"); @@ -635,8 +639,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid"); b.Property("Status") - .HasDefaultValue(0) - .HasColumnType("integer"); + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); diff --git a/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs b/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs index b92bfe0..380a3f6 100644 --- a/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs +++ b/backend/tests/Loopless.UnitTests/Visibility/ProjectVisibilityGuardTests.cs @@ -66,14 +66,37 @@ private static async Task SeedAsync() CreatedAt = DateTimeOffset.UtcNow, }); - db.GitHubCommits.Add(new GitHubCommit - { - ProjectId = project.Id, - Sha = "abc123", - Message = "init", - Author = "member", - CommittedAt = DateTimeOffset.UtcNow, - }); + db.GitHubCommits.AddRange( + // Authored by a team member (email matches the member's account) -> visible. + new GitHubCommit + { + ProjectId = project.Id, + Sha = "abc123", + Message = "init", + Author = member.Name, + AuthorEmail = member.Email, + CommittedAt = DateTimeOffset.UtcNow, + }, + // Authored by someone not on the team -> hidden. + new GitHubCommit + { + ProjectId = project.Id, + Sha = "def456", + Message = "drive-by", + Author = "Random Stranger", + AuthorEmail = "stranger@elsewhere.dev", + CommittedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + }, + // Legacy row synced before AuthorEmail existed -> hidden until backfilled. + new GitHubCommit + { + ProjectId = project.Id, + Sha = "ghi789", + Message = "legacy", + Author = "member", + AuthorEmail = null, + CommittedAt = DateTimeOffset.UtcNow.AddMinutes(-10), + }); await db.SaveChangesAsync(); return new Fixture(db, project, owner, member, outsider); @@ -136,11 +159,36 @@ await Assert.ThrowsAsync(() => } [Fact] - public async Task Commits_Owner_SeesCommits() + public async Task Commits_Owner_SeesOnlyMemberAuthoredCommits() { var f = await SeedAsync(); var handler = new GetProjectCommitsQueryHandler(f.Db, new FakeCurrentUser(f.Owner.KeycloakId)); var result = await handler.Handle(new GetProjectCommitsQuery(f.Project.Id, 1, 20), CancellationToken.None); + + // Of the three seeded commits, only the member-authored one is returned — + // the stranger-authored and legacy (null-email) commits are filtered out. Assert.Equal(1, result.TotalCount); + Assert.Equal("abc123", Assert.Single(result.Items).Sha); + } + + [Fact] + public async Task Commits_EmailMatchIsCaseInsensitive() + { + var f = await SeedAsync(); + f.Db.GitHubCommits.Add(new GitHubCommit + { + ProjectId = f.Project.Id, + Sha = "upper1", + Message = "shout", + Author = f.Member.Name, + AuthorEmail = f.Member.Email.ToUpperInvariant(), + CommittedAt = DateTimeOffset.UtcNow.AddMinutes(1), + }); + await f.Db.SaveChangesAsync(); + + var handler = new GetProjectCommitsQueryHandler(f.Db, new FakeCurrentUser(f.Member.KeycloakId)); + var result = await handler.Handle(new GetProjectCommitsQuery(f.Project.Id, 1, 20), CancellationToken.None); + + Assert.Contains(result.Items, c => c.Sha == "upper1"); } } diff --git a/devops/aiops/Dockerfile b/devops/aiops/Dockerfile index 6b7f252..67e9392 100644 --- a/devops/aiops/Dockerfile +++ b/devops/aiops/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-alpine WORKDIR /app -COPY server.js ./ +COPY server.js sanitize.js ./ ENV NODE_ENV=production ENV PORT=8085 diff --git a/frontend/client/app/(protected)/projects/[id]/page.tsx b/frontend/client/app/(protected)/projects/[id]/page.tsx index a483346..bf6f522 100644 --- a/frontend/client/app/(protected)/projects/[id]/page.tsx +++ b/frontend/client/app/(protected)/projects/[id]/page.tsx @@ -550,12 +550,15 @@ export default function ProjectDashboardPage({ {summaryMutation.isPending ? "Generating…" : "Generate"} - {summary ? ( + {summary?.content ? (

{summary.content}

-

- Generated {new Date(summary.generatedAt).toLocaleString()} -

+ {summary.generatedAt && + Number.isFinite(new Date(summary.generatedAt).getTime()) && ( +

+ Generated {new Date(summary.generatedAt).toLocaleString()} +

+ )}
) : (

@@ -757,25 +760,28 @@ export default function ProjectDashboardPage({ className="mt-0.5 h-4 w-4 shrink-0 text-[var(--mute)]" />

-

{commit.message}

+
+ {commitUrl ? ( + + {commit.sha.slice(0, 7)} + + ) : ( + + {commit.sha.slice(0, 7)} + + )} +

{commit.message}

+

{commit.authorName} · {new Date(commit.committedAt).toLocaleDateString()}

- {commitUrl ? ( - - {commit.sha.slice(0, 7)} - - ) : ( - - {commit.sha.slice(0, 7)} - - )} ); })} diff --git a/frontend/client/hooks/useProjects.ts b/frontend/client/hooks/useProjects.ts index ea7e07e..00ec0d5 100644 --- a/frontend/client/hooks/useProjects.ts +++ b/frontend/client/hooks/useProjects.ts @@ -103,11 +103,9 @@ export function useTriggerCommitSync(projectId: string) { return useMutation({ mutationFn: () => triggerCommitSync(projectId), onSuccess: () => { - // The endpoint returns 202 — the sync runs as a background job, so give it - // a few seconds to land before refetching the commit list. - setTimeout(() => { - qc.invalidateQueries({ queryKey: ["projects", projectId, "commits"] }); - }, 4000); + // The sync now fetches + stores commits synchronously before responding, so the + // freshly stored commits are already queryable — refetch immediately. + qc.invalidateQueries({ queryKey: ["projects", projectId, "commits"] }); }, }); }