Add per-user Like (پسندیدن) with a liked page and counts
CI/CD / CI · dotnet build (push) Successful in 2m54s
CI/CD / Deploy · hamkadr (push) Successful in 2m48s

Logged-in users can like a listing (job/shift/talent); dislike is removed per request — only likes.
- Like model (polymorphic by TargetType+TargetId) + EF migration; unique per (user, listing).
- POST /like toggles the like (auth required) and returns {liked, count}.
- Detail pages: the old ♡ Save / ✕ Dismiss buttons are replaced by a single heart Like button that
  shows the live count and toggles in place; clicking while logged out redirects to login.
- New «❤️ پسندیده‌ها» page (/Me/Liked) lists everything the user liked (open listings only), with a
  nav entry shown only when authenticated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-23 12:25:10 +03:30
parent 39c866f4c7
commit c1c914df9f
16 changed files with 2002 additions and 24 deletions
+7
View File
@@ -34,6 +34,7 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
public DbSet<IngestionRun> IngestionRuns => Set<IngestionRun>();
public DbSet<Review> Reviews => Set<Review>();
public DbSet<Like> Likes => Set<Like>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -167,6 +168,12 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
.HasOne(c => c.JobOpening).WithMany(j => j.Contacts)
.HasForeignKey(c => c.JobOpeningId).OnDelete(DeleteBehavior.Cascade);
// One like per user per listing; fast count by target.
b.Entity<Like>().HasIndex(l => new { l.UserId, l.TargetType, l.TargetId }).IsUnique();
b.Entity<Like>().HasIndex(l => new { l.TargetType, l.TargetId });
b.Entity<Like>().HasOne(l => l.User).WithMany()
.HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
b.Entity<Notification>()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class Likes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Likes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
TargetType = table.Column<int>(type: "integer", nullable: false),
TargetId = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Likes", x => x.Id);
table.ForeignKey(
name: "FK_Likes_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Likes_TargetType_TargetId",
table: "Likes",
columns: new[] { "TargetType", "TargetId" });
migrationBuilder.CreateIndex(
name: "IX_Likes_UserId_TargetType_TargetId",
table: "Likes",
columns: new[] { "UserId", "TargetType", "TargetId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Likes");
}
}
}
@@ -727,6 +727,36 @@ namespace JobsMedical.Web.Migrations
b.ToTable("JobOpenings");
});
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("TargetId")
.HasColumnType("integer");
b.Property<int>("TargetType")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TargetType", "TargetId");
b.HasIndex("UserId", "TargetType", "TargetId")
.IsUnique();
b.ToTable("Likes");
});
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
{
b.Property<long>("Id")
@@ -1467,6 +1497,17 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
{
b.HasOne("JobsMedical.Web.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
{
b.HasOne("JobsMedical.Web.Models.User", "User")
+3
View File
@@ -119,6 +119,9 @@ public enum ContactType
Other = 8 // سایر
}
/// <summary>What a <see cref="Like"/> points at.</summary>
public enum LikeTargetType { Shift = 0, Job = 1, Talent = 2 }
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
+19
View File
@@ -0,0 +1,19 @@
namespace JobsMedical.Web.Models;
/// <summary>
/// A logged-in user's «پسندیدن» of a listing (shift / job / talent). One row per (user, listing);
/// toggling removes it. Polymorphic by <see cref="TargetType"/> + <see cref="TargetId"/> so one table
/// covers all three listing kinds. The count of rows for a target is the public "likes" number.
/// </summary>
public class Like
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
public LikeTargetType TargetType { get; set; }
public int TargetId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+9 -11
View File
@@ -119,14 +119,11 @@
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
</div>
<div style="display:flex; gap:8px; margin-top:8px;">
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-block">♡ ذخیره</button>
</form>
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:8px;"
data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
@if (Model.Reported)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
@@ -197,9 +194,10 @@
<div class="mobile-action-bar">
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
<form method="post">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
</form>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
</div>
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
@@ -23,6 +23,8 @@ public class DetailsModel : PageModel
public JobOpening? Job { get; private set; }
public string? MapKey { get; private set; }
public int LikeCount { get; private set; }
public bool IsLiked { get; private set; }
public bool ShowContact { get; private set; }
public bool Saved { get; private set; }
public bool Reported { get; private set; }
@@ -35,6 +37,9 @@ public class DetailsModel : PageModel
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
if (Job.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
MapKey = (await _settings.GetAsync()).NeshanMapKey;
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Job && l.TargetId == id);
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Job && l.TargetId == id);
Reported = Request.Query["reported"] == "1";
await _interest.LogJobAsync(InterestEventType.View, id);
return Page();
+47
View File
@@ -0,0 +1,47 @@
@page
@model JobsMedical.Web.Pages.Me.LikedModel
@{
ViewData["Title"] = "پسندیده‌ها";
}
<div class="page-head">
<div class="container">
<h1>❤️ پسندیده‌ها</h1>
<p class="muted">فرصت‌هایی که پسندیده‌ای — برای حذف، دوباره روی دکمهٔ ♥ بزن.</p>
</div>
</div>
<div class="container section">
@if (Model.Total == 0)
{
<div class="card empty-state">
هنوز چیزی نپسندیده‌ای. در <a asp-page="/Jobs/Index">استخدام</a>،
<a asp-page="/Shifts/Index">شیفت‌ها</a> و <a asp-page="/Talent/Index">آماده‌به‌کار</a>
فرصت‌ها را ببین و آن‌هایی که دوست داری را با دکمهٔ ♥ پسند کن.
</div>
}
@if (Model.Jobs.Count > 0)
{
<div class="section-head"><h2>موقعیت‌های استخدامی (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
</div>
}
@if (Model.Shifts.Count > 0)
{
<div class="section-head" style="margin-top:18px;"><h2>شیفت‌ها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
@if (Model.Talent.Count > 0)
{
<div class="section-head" style="margin-top:18px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
</div>
}
</div>
@@ -0,0 +1,45 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Me;
/// <summary>«پسندیده‌ها» — the listings the logged-in user has liked (jobs, shifts, talent), newest
/// first. Only still-open listings are shown; un-liking is done with the same heart button.</summary>
[Authorize]
public class LikedModel : PageModel
{
private readonly AppDbContext _db;
public LikedModel(AppDbContext db) => _db = db;
public List<JobOpening> Jobs { get; private set; } = new();
public List<Shift> Shifts { get; private set; } = new();
public List<TalentListing> Talent { get; private set; } = new();
public int Total => Jobs.Count + Shifts.Count + Talent.Count;
public async Task OnGetAsync()
{
var uid = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
var likes = await _db.Likes.Where(l => l.UserId == uid)
.OrderByDescending(l => l.CreatedAt).ToListAsync();
var jobIds = likes.Where(l => l.TargetType == LikeTargetType.Job).Select(l => l.TargetId).ToList();
var shiftIds = likes.Where(l => l.TargetType == LikeTargetType.Shift).Select(l => l.TargetId).ToList();
var talentIds = likes.Where(l => l.TargetType == LikeTargetType.Talent).Select(l => l.TargetId).ToList();
Jobs = await _db.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District)
.Include(j => j.Role)
.Where(j => jobIds.Contains(j.Id) && j.Status == ShiftStatus.Open).ToListAsync();
Shifts = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.Where(s => shiftIds.Contains(s.Id) && s.Status == ShiftStatus.Open).ToListAsync();
Talent = await _db.TalentListings
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => talentIds.Contains(t.Id) && t.Status == ShiftStatus.Open).ToListAsync();
}
}
@@ -112,6 +112,10 @@
<nav class="main-nav">
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
<a asp-page="/Recommendations/Index" class="@(path.StartsWith("/Recommendations") ? "active" : null)">✨ پیشنهادها</a>
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-page="/Me/Liked" class="@(path.StartsWith("/Me/Liked") ? "active" : null)">❤️ پسندیده‌ها</a>
}
<a href="/Shifts" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a href="/Jobs" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
@@ -290,6 +294,36 @@
<div id="contactModalBody" class="contact-modal-body"></div>
</div>
</div>
@* Like («پسندیدن») toggle — login-gated, updates the button state + count in place. *@
<script>
(function () {
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
document.addEventListener('click', function (e) {
var b = e.target.closest('.like-trigger');
if (!b) return;
e.preventDefault();
if (document.body.dataset.authed !== '1') {
location.href = '/Account/Login?returnUrl=' + encodeURIComponent(location.pathname);
return;
}
var fd = new FormData();
fd.append('type', b.dataset.likeType);
fd.append('id', b.dataset.likeId);
b.disabled = true;
fetch('/like', { method: 'POST', body: fd })
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (d) {
b.dataset.liked = d.liked ? 'true' : 'false';
b.classList.toggle('btn-accent', d.liked);
b.classList.toggle('btn-outline', !d.liked);
var ico = b.querySelector('.like-ico'); if (ico) ico.textContent = d.liked ? '♥' : '♡';
var c = b.querySelector('.like-count'); if (c) c.textContent = fa(d.count);
})
.catch(function () {})
.finally(function () { b.disabled = false; });
});
})();
</script>
<script>
(function () {
var modal = document.getElementById('contactModal');
@@ -135,16 +135,11 @@
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده می‌شود.</p>
</div>
<div style="display:flex; gap:8px;">
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id"
class="btn btn-outline btn-block">♡ ذخیره</button>
</form>
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@s.Id"
class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger"
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
@if (Model.Reported)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
@@ -212,9 +207,10 @@
<div class="mobile-action-bar">
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده تماس</button>
<form method="post">
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
</form>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
</div>
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
@@ -24,6 +24,8 @@ public class DetailsModel : PageModel
public Shift? Shift { get; private set; }
public List<Shift> MoreAtFacility { get; private set; } = new();
public string? MapKey { get; private set; }
public int LikeCount { get; private set; }
public bool IsLiked { get; private set; }
// Set after the visitor taps "interested" — reveals the facility contact (handoff model).
public bool ShowContact { get; private set; }
@@ -38,6 +40,9 @@ public class DetailsModel : PageModel
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
if (Shift.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
MapKey = (await _settings.GetAsync()).NeshanMapKey;
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Shift && l.TargetId == id);
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Shift && l.TargetId == id);
Reported = Request.Query["reported"] == "1";
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
return Page();
@@ -60,6 +60,11 @@
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
data-contact-type="talent" data-contact-id="@t.Id">📞 مشاهده راه‌های ارتباطی</button>
<p class="muted" style="font-size:12px; margin:10px 0 0;">با کلیک، شماره تماس و راه‌های ارتباطی نمایش داده می‌شود.</p>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:10px;"
data-like-type="talent" data-like-id="@t.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
</div>
@if (t.Lat is not null && t.Lng is not null)
@@ -19,6 +19,8 @@ public class DetailsModel : PageModel
public TalentListing? Item { get; private set; }
public string? MapKey { get; private set; }
public int LikeCount { get; private set; }
public bool IsLiked { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
@@ -30,6 +32,9 @@ public class DetailsModel : PageModel
.FirstOrDefaultAsync(t => t.Id == id);
if (Item is null) return NotFound();
MapKey = (await _settings.GetAsync()).NeshanMapKey;
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Talent && l.TargetId == id);
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Talent && l.TargetId == id);
return Page();
}
}
+18
View File
@@ -295,6 +295,24 @@ app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext v
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
}).DisableAntiforgery();
// Toggle a logged-in user's «پسندیدن» of a listing; returns the new state + total like count.
app.MapPost("/like", async (HttpContext ctx, AppDbContext db, [FromForm] string type, [FromForm] int id) =>
{
int? uid = ctx.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier) is { } c
&& int.TryParse(c.Value, out var n) ? n : null;
if (uid is null) return Results.Unauthorized();
if (!Enum.TryParse<LikeTargetType>(type, true, out var tt)) return Results.BadRequest();
var existing = await db.Likes.FirstOrDefaultAsync(l => l.UserId == uid && l.TargetType == tt && l.TargetId == id);
bool liked;
if (existing is null) { db.Likes.Add(new Like { UserId = uid.Value, TargetType = tt, TargetId = id }); liked = true; }
else { db.Likes.Remove(existing); liked = false; }
await db.SaveChangesAsync();
var count = await db.Likes.CountAsync(l => l.TargetType == tt && l.TargetId == id);
return Results.Json(new { liked, count });
}).RequireAuthorization().DisableAntiforgery();
app.MapGet("/sw.js", () => Results.Content("""
const CACHE = 'hamkadr-v1';
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });