diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml new file mode 100644 index 0000000..71553a2 --- /dev/null +++ b/.gitea/workflows/ci-deploy.yml @@ -0,0 +1,72 @@ +name: CI and Deploy + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: job-tracker-ui/package-lock.json + + - name: Build backend + run: dotnet build JobTrackerApi/JobTrackerApi.csproj --configuration Release + + - name: Test backend + run: dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-build + + - name: Install frontend deps + working-directory: job-tracker-ui + run: npm ci + + - name: Test frontend + working-directory: job-tracker-ui + run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx + + deploy: + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Upload deploy bundle to server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.PROD_HOST }} + username: ${{ secrets.PROD_USER }} + key: ${{ secrets.PROD_SSH_KEY }} + source: "." + target: "/opt/job-tracker/app" + rm: true + + - name: Run remote deploy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PROD_HOST }} + username: ${{ secrets.PROD_USER }} + key: ${{ secrets.PROD_SSH_KEY }} + script: | + cd /opt/job-tracker/app + chmod +x deploy/deploy.sh + APP_VERSION=${{ github.run_number }} \ + APP_COMMIT_SHA=${{ github.sha }} \ + APP_BUILD_STAMP="$(date -u +'%Y-%m-%d %H:%M UTC')" \ + ./deploy/deploy.sh diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs new file mode 100644 index 0000000..e528551 --- /dev/null +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -0,0 +1,85 @@ +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 JobApplicationsEndpointBehaviorTests +{ + [Fact] + public async Task Save_application_drafts_updates_cover_letter_and_notes() + { + 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(" Cover letter body ", " Notes body "), CancellationToken.None); + + Assert.IsType(result); + var saved = await db.JobApplications.FirstAsync(); + Assert.Equal("Cover letter body", saved.CoverLetterText); + Assert.Contains("Notes body", saved.Notes); + } + + [Fact] + public async Task Generate_application_package_rejects_missing_profile_cv() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "u", Email = "u@example.com" }); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Backend Dev", CompanyId = company.Id, OwnerUserId = "user-1", Description = "Need .NET and SQL" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var controller = CreateController(db, "user-1"); + var result = await controller.GenerateApplicationPackage(job.Id, null, CancellationToken.None); + + var badRequest = Assert.IsType(result.Result); + Assert.Contains("Profile page", badRequest.Value?.ToString()); + } + + 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.Tests/JobTrackerApi.Tests.csproj b/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj index fe6c767..e972ef7 100644 --- a/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj +++ b/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj @@ -14,6 +14,7 @@ all + diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..121de00 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,53 @@ +# Production deployment notes + +## Gitea Actions +This repo includes `.gitea/workflows/ci-deploy.yml` for: +- backend build +- backend tests +- frontend tests +- deployment to Ubuntu after successful tests on `main` + +### Required secrets in Gitea +- `PROD_HOST` +- `PROD_USER` +- `PROD_SSH_KEY` + +## Ubuntu server setup +Recommended app path: +- `/opt/job-tracker/app` + +Requirements: +- Docker Engine +- Docker Compose plugin +- reverse proxy in front (Nginx, Caddy, or Traefik) +- `.env` file present on server in `/opt/job-tracker/app/.env` + +## Database recommendation +For production, yes — use a real database. + +### Recommended direction +Short term: +- SQLite is acceptable for a single-user or very small deployment +- keep backups and volume persistence + +Better production choice: +- MariaDB or PostgreSQL + +### My recommendation +- **PostgreSQL** if you want the best long-term maintainability and fewer edge cases +- **MariaDB** is also fine if that is what you already know or host elsewhere + +If you stay on SQLite: +- okay for small personal use +- not ideal for concurrent writes, larger scale, or operational robustness + +## Practical recommendation for this project +If this app is going to be a real production service on Ubuntu: +- move to PostgreSQL first if possible +- MariaDB is still a reasonable option if preferred + +## Deployment flow +1. push to `main` +2. Gitea Actions runs tests +3. if green, workflow uploads repo to server +4. `deploy/deploy.sh` runs `docker compose build && docker compose up -d` diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..f973aca --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + echo "Missing .env in deployment directory" + exit 1 +fi + +export APP_VERSION="${APP_VERSION:-0.0.0}" +export APP_COMMIT_SHA="${APP_COMMIT_SHA:-unknown}" +export APP_BUILD_STAMP="${APP_BUILD_STAMP:-unknown}" + +docker compose pull || true +docker compose build +docker compose up -d --remove-orphans + +echo "Deployment complete: ${APP_VERSION} ${APP_COMMIT_SHA}" diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 8622148..ac4dd49 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -462,16 +462,22 @@ function ListCard({ title, items }: { title: string; items: string[] }) { } function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise | void; saving?: boolean }) { + const [value, setValue] = React.useState(content); + + React.useEffect(() => { + setValue(content); + }, [content]); + return ( {title} - - {onSave ? : null} + + {onSave ? : null} - {content} + setValue(e.target.value)} multiline minRows={6} fullWidth /> ); }