feat: add mariadb production support deploy hardening and recruiter drafts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
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 ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
|
||||
|
||||
@@ -1418,8 +1418,35 @@ Candidate CV/profile:
|
||||
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")]
|
||||
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
|
||||
.Include(j => j.Company)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -319,7 +319,19 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
}} 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} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface ApplicationPackageResponse {
|
||||
export interface SaveApplicationDraftsRequest {
|
||||
coverLetterText?: string | null;
|
||||
notes?: string | null;
|
||||
recruiterMessageDraft?: string | null;
|
||||
}
|
||||
|
||||
export interface CorrespondenceMessage {
|
||||
|
||||
Reference in New Issue
Block a user