feat: add mariadb production support deploy hardening and recruiter drafts

This commit is contained in:
cesnimda
2026-03-22 18:53:41 +01:00
parent 1fe3a68901
commit 16b9960c08
11 changed files with 130 additions and 9 deletions
+1
View File
@@ -70,3 +70,4 @@ jobs:
APP_COMMIT_SHA=${{ github.sha }} \ APP_COMMIT_SHA=${{ github.sha }} \
APP_BUILD_STAMP="$(date -u +'%Y-%m-%d %H:%M UTC')" \ APP_BUILD_STAMP="$(date -u +'%Y-%m-%d %H:%M UTC')" \
./deploy/deploy.sh ./deploy/deploy.sh
docker compose ps
@@ -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<NoContentResult>(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<ISummarizerService>();
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text");
var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of<IAppEmailSender>());
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<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
}
}
@@ -1223,7 +1223,7 @@ namespace JobTrackerApi.Controllers
string? RecruiterMessageDraft); string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText); public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints); public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> 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<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots); public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders); public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
@@ -1418,8 +1418,35 @@ Candidate CV/profile:
return NoContent(); return NoContent();
} }
[HttpPut("{id:int}/application-drafts")]
public async Task<IActionResult> 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")] [HttpPost("{id:int}/generate-application-package")]
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, CancellationToken cancellationToken) public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
{ {
var job = await _db.JobApplications var job = await _db.JobApplications
.Include(j => j.Company) .Include(j => j.Company)
+1
View File
@@ -18,6 +18,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1 -6
View File
@@ -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", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText 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", "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. // Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
@@ -622,9 +623,3 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
+1
View File
@@ -16,6 +16,7 @@ public class JobApplication
public string? NextAction { get; set; } public string? NextAction { get; set; }
public DateTime? FollowUpAt { get; set; } public DateTime? FollowUpAt { get; set; }
public DateTime? FeedbackRequestedAt { get; set; } public DateTime? FeedbackRequestedAt { get; set; }
public string? RecruiterMessageDraft { get; set; }
// Attachment checklist // Attachment checklist
public bool HasResume { get; set; } = false; public bool HasResume { get; set; } = false;
+12
View File
@@ -21,6 +21,18 @@ Requirements:
- Docker Compose plugin - Docker Compose plugin
- reverse proxy in front (Nginx, Caddy, or Traefik) - reverse proxy in front (Nginx, Caddy, or Traefik)
- `.env` file present on server in `/opt/job-tracker/app/.env` - `.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 ## Database recommendation
For production, yes — use a real database. For production, yes — use a real database.
+8
View File
@@ -16,4 +16,12 @@ docker compose pull || true
docker compose build docker compose build
docker compose up -d --remove-orphans 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}" echo "Deployment complete: ${APP_VERSION} ${APP_COMMIT_SHA}"
+2
View File
@@ -10,6 +10,8 @@ services:
- ASPNETCORE_URLS=http://+:8080 - ASPNETCORE_URLS=http://+:8080
- Data__Root=/data - Data__Root=/data
- Exports__DailyFolder=/data/exports - 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. # If you enable HTTPS at a reverse proxy (recommended), handle redirects there.
- HttpsRedirection__Enabled=false - HttpsRedirection__Enabled=false
# Authentication (recommended for any non-local deployment) # Authentication (recommended for any non-local deployment)
@@ -319,7 +319,19 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
setSavingApplicationDrafts(false); setSavingApplicationDrafts(false);
} }
}} saving={savingApplicationDrafts} /> }} saving={savingApplicationDrafts} />
<DraftCard title="Recruiter message draft" content={applicationPackage.recruiterMessageDraft ?? "No draft available."} /> <DraftCard title="Recruiter message draft" content={applicationPackage.recruiterMessageDraft ?? "No draft available."} onSave={async (content) => {
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} />
<ListCard title="Key points to emphasize" items={applicationPackage.keyPoints} /> <ListCard title="Key points to emphasize" items={applicationPackage.keyPoints} />
</Box> </Box>
) : null} ) : null}
+1
View File
@@ -98,6 +98,7 @@ export interface ApplicationPackageResponse {
export interface SaveApplicationDraftsRequest { export interface SaveApplicationDraftsRequest {
coverLetterText?: string | null; coverLetterText?: string | null;
notes?: string | null; notes?: string | null;
recruiterMessageDraft?: string | null;
} }
export interface CorrespondenceMessage { export interface CorrespondenceMessage {