diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index 71553a2..477e322 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -70,3 +70,4 @@ jobs: APP_COMMIT_SHA=${{ github.sha }} \ APP_BUILD_STAMP="$(date -u +'%Y-%m-%d %H:%M UTC')" \ ./deploy/deploy.sh + docker compose ps diff --git a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs new file mode 100644 index 0000000..f3f5c9d --- /dev/null +++ b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs @@ -0,0 +1,61 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobApplicationsMariaDraftTests +{ + [Fact] + public async Task Save_application_drafts_can_store_recruiter_message() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Backend Dev", CompanyId = company.Id, OwnerUserId = "user-1" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.SaveApplicationDrafts(job.Id, new JobApplicationsController.SaveApplicationDraftsRequest(null, null, " Recruiter hello "), CancellationToken.None); + + Assert.IsType(result); + var saved = await db.JobApplications.FirstAsync(); + Assert.Equal("Recruiter hello", saved.RecruiterMessageDraft); + } + + private static JobApplicationsController CreateController(JobTrackerContext db, string userId) + { + var summarizer = new Mock(); + summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("generated text"); + + var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) + } + }; + return controller; + } + + private static JobTrackerContext CreateDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + currentUser.SetupGet(x => x.UserId).Returns("user-1"); + return new JobTrackerContext(options, currentUser.Object); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index b61999a..adfc4eb 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1223,7 +1223,7 @@ namespace JobTrackerApi.Controllers string? RecruiterMessageDraft); public sealed record SaveTailoredCvRequest(string? TailoredCvText); public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List KeyPoints); - public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes); + public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft); public sealed record InterviewPrepDto(string Summary, List TalkingPoints, List LikelyQuestions, List WeakSpots); public sealed record ReadinessDto(int Score, string Level, List Completed, List Missing, List Reminders); @@ -1418,8 +1418,35 @@ Candidate CV/profile: return NoContent(); } + [HttpPut("{id:int}/application-drafts")] + public async Task SaveApplicationDrafts([FromRoute] int id, [FromBody] SaveApplicationDraftsRequest request, CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + if (!string.IsNullOrWhiteSpace(request.CoverLetterText)) + { + job.CoverLetterText = request.CoverLetterText.Trim(); + } + + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + job.Notes = string.IsNullOrWhiteSpace(job.Notes) + ? request.Notes.Trim() + : $"{job.Notes.Trim()}\n\n{request.Notes.Trim()}"; + } + + if (!string.IsNullOrWhiteSpace(request.RecruiterMessageDraft)) + { + job.RecruiterMessageDraft = request.RecruiterMessageDraft.Trim(); + } + + await _db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + [HttpPost("{id:int}/generate-application-package")] - public async Task> GenerateApplicationPackage([FromRoute] int id, CancellationToken cancellationToken) + public async Task> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken) { var job = await _db.JobApplications .Include(j => j.Company) diff --git a/JobTrackerApi/JobTrackerApi.csproj b/JobTrackerApi/JobTrackerApi.csproj index dc70002..13fabba 100644 --- a/JobTrackerApi/JobTrackerApi.csproj +++ b/JobTrackerApi/JobTrackerApi.csproj @@ -18,6 +18,7 @@ all + diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 1dd7967..fa4fb9b 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -550,6 +550,7 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;"); EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;"); // Ensure ownership columns exist even on non-legacy DBs. EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); @@ -622,9 +623,3 @@ app.UseAuthorization(); app.MapControllers(); app.Run(); - - - - - - diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index f23b724..42021cf 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -16,6 +16,7 @@ public class JobApplication public string? NextAction { get; set; } public DateTime? FollowUpAt { get; set; } public DateTime? FeedbackRequestedAt { get; set; } + public string? RecruiterMessageDraft { get; set; } // Attachment checklist public bool HasResume { get; set; } = false; diff --git a/deploy/README.md b/deploy/README.md index 121de00..47c1d21 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -21,6 +21,18 @@ Requirements: - Docker Compose plugin - reverse proxy in front (Nginx, Caddy, or Traefik) - `.env` file present on server in `/opt/job-tracker/app/.env` +- network connectivity from the backend container to your `mariadb` container/service + +### Example production `.env` +```env +DATABASE_PROVIDER=mariadb +JOBTRACKER_CONNECTION_STRING=server=mariadb;port=3306;database=jobtracker;user=jobtracker;password=REPLACE_ME +AUTH_JWT_KEY=replace_with_long_random_secret +AUTH_ADMIN_EMAIL=you@example.com +AUTH_ADMIN_PASSWORD=replace_with_strong_password +APP_PUBLIC_BASE_URL=https://your-domain.example +SUMMARIZER_BASE_URL=http://summarizer:8001 +``` ## Database recommendation For production, yes — use a real database. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index f973aca..f368379 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -16,4 +16,12 @@ docker compose pull || true docker compose build docker compose up -d --remove-orphans +sleep 5 +docker compose ps + +if ! docker compose ps | grep -q "backend"; then + echo "Backend service not running after deploy" + exit 1 +fi + echo "Deployment complete: ${APP_VERSION} ${APP_COMMIT_SHA}" diff --git a/docker-compose.yml b/docker-compose.yml index 4481aa5..ef84e09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - ASPNETCORE_URLS=http://+:8080 - Data__Root=/data - Exports__DailyFolder=/data/exports + - Database__Provider=${DATABASE_PROVIDER:-sqlite} + - ConnectionStrings__JobTracker=${JOBTRACKER_CONNECTION_STRING} # If you enable HTTPS at a reverse proxy (recommended), handle redirects there. - HttpsRedirection__Enabled=false # Authentication (recommended for any non-local deployment) diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index ac4dd49..7bf1da7 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -319,7 +319,19 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { setSavingApplicationDrafts(false); } }} saving={savingApplicationDrafts} /> - + { + if (!jobId) return; + setSavingApplicationDrafts(true); + try { + await api.put(`/jobapplications/${jobId}/application-drafts`, { recruiterMessageDraft: content }); + setJob((prev) => prev ? { ...prev, recruiterMessageDraft: content } : prev); + toast("Recruiter message saved to this job.", "success"); + } catch (error: any) { + toast(error?.response?.data || "Failed to save recruiter message.", "error"); + } finally { + setSavingApplicationDrafts(false); + } + }} saving={savingApplicationDrafts} /> ) : null} diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index c6f39b2..8e4edd6 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -98,6 +98,7 @@ export interface ApplicationPackageResponse { export interface SaveApplicationDraftsRequest { coverLetterText?: string | null; notes?: string | null; + recruiterMessageDraft?: string | null; } export interface CorrespondenceMessage {