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_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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user