📄 BfiScreeningCheckerJob.cs
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Playwright;
using Quartz;

[DisallowConcurrentExecution]
internal sealed class BfiScreeningCheckerJob(
    PlaywrightBrowserService browserService,
    ScreeningRepository repository,
    IOptions<MonitorOptions> options,
    ILogger<BfiScreeningCheckerJob> logger
) : IJob
{
    private static readonly Action<ILogger, int, Exception?> LogCheckComplete = LoggerMessage.Define<int>(
        LogLevel.Information,
        new EventId(1, nameof(LogCheckComplete)),
        "Check complete — {Count} available screening(s) across all pages"
    );

    private static readonly Action<ILogger, string, string, Exception?> LogSmsScheduled = LoggerMessage.Define<
        string,
        string
    >(
        LogLevel.Information,
        new EventId(2, nameof(LogSmsScheduled)),
        "Scheduled SMS to {Phone} for screening {ScreeningId}"
    );

    public async Task Execute(IJobExecutionContext context)
    {
        var opts = options.Value;
        var page = await browserService.NewPageAsync();
        try
        {
            var available = new HashSet<string>();

            await page.GotoAsync(opts.Url, new() { WaitUntil = WaitUntilState.DOMContentLoaded });
            await page.WaitForSelectorAsync(".result-box-item");

            while (true)
            {
                var availableItems = await page.QuerySelectorAllAsync(".result-box-item:has(.item-link.good)");
                foreach (var item in availableItems)
                {
                    var startDateEl = await item.QuerySelectorAsync(".start-date");
                    if (startDateEl is null)
                        continue;
                    var dateText = (await startDateEl.InnerTextAsync()).Trim();
                    if (!string.IsNullOrEmpty(dateText))
                        available.Add(dateText);
                }

                // Follow the next-page link if present.
                var nextLink = await page.QuerySelectorAsync("#av-next-link a");
                if (nextLink is null)
                    break;
                var nextHref = await nextLink.GetAttributeAsync("href");
                if (string.IsNullOrEmpty(nextHref))
                    break;

                var nextUrl = new Uri(new Uri(page.Url), nextHref).ToString();
                await page.GotoAsync(nextUrl, new() { WaitUntil = WaitUntilState.DOMContentLoaded });
                await page.WaitForSelectorAsync(".result-box-item");
            }

            LogCheckComplete(logger, available.Count, null);

            foreach (var screeningId in available)
            {
                if (await repository.HasBeenNotifiedAsync(screeningId))
                    continue;

                await repository.MarkNotifiedAsync(screeningId);

                var message = $"BFI IMAX tickets available! {screeningId} — Book now: {opts.Url}";

                foreach (var phone in opts.PhoneNumbers)
                {
                    var dataMap = new JobDataMap { { "phoneNumber", phone }, { "message", message } };

                    var job = JobBuilder
                        .Create<SendSmsJob>()
                        .WithIdentity(Guid.NewGuid().ToString(), "sms")
                        .UsingJobData(dataMap)
                        .Build();

                    var trigger = TriggerBuilder.Create().StartNow().Build();

                    await context.Scheduler.ScheduleJob(job, trigger, context.CancellationToken);
                    LogSmsScheduled(logger, phone, screeningId, null);
                }
            }
        }
        finally
        {
            await page.CloseAsync();
        }
    }
}