using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Domain.Media; using PrivaPub.Domain.Statuses; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Media; using PrivaPub.Models.Social; namespace PrivaPub.Api.Mastodon.Controllers { // Mastodon's scheduled statuses (owner decision 2026-10-04, back from the cut list): the persona's posts waiting for // their time, moved to another time or dropped. POST /api/v1/statuses with scheduled_at makes them. public class ScheduledStatusesController : MastodonController { readonly IMediaService _media; readonly IJobQueue _jobs; public ScheduledStatusesController(IMediaService media, IJobQueue jobs) { _media = media; _jobs = jobs; } public static async Task View(ScheduledStatus scheduled, IMediaService media, CancellationToken token) { var p = scheduled.Params; var ids = p.MediaIds; var attachments = ids.Count == 0 ? new List() : await DB.Default.Find().Match(m => ids.Contains(m.ID)).ExecuteAsync(token); return new { id = scheduled.ID, scheduled_at = MastodonJson.Time(scheduled.ScheduledAt), @params = new { text = p.Text, poll = p.Poll == default ? default : new { options = p.Poll.Options, expires_in = p.Poll.ExpiresIn, multiple = p.Poll.Multiple, hide_totals = p.Poll.HideTotals }, media_ids = ids.Count == 0 ? default : ids, sensitive = p.Sensitive, spoiler_text = p.SpoilerText, visibility = p.Visibility, in_reply_to_id = p.InReplyToId, language = p.Language, application_id = default(string), scheduled_at = default(string), idempotency = p.Idempotency, with_rate_limit = false, quoted_status_id = p.QuotedStatusId }, media_attachments = ids.Select(id => attachments.FirstOrDefault(m => m.ID == id)).Where(m => m != default) .Select(m => MediaController.View(media, m)).ToList() }; } Task Mine(string id, CancellationToken token) => DB.Default.Find().Match(s => s.ID == id && s.AvatarId == MyId).ExecuteFirstAsync(token); [HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")] public async Task All(CancellationToken token) { var page = await Page.From(Params, Limit(20, 40)).Fetch(DB.Default.Find().Match(s => s.AvatarId == MyId), s => s.ID, token); Link("/api/v1/scheduled_statuses", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID); var views = new List(); foreach (var scheduled in page) views.Add(await View(scheduled, _media, token)); return Json(views); } [HttpGet("/api/v1/scheduled_statuses/{id}"), Scope("read:statuses")] public async Task One(string id, CancellationToken token) => await Mine(id, token) is { } scheduled ? Json(await View(scheduled, _media, token)) : NotFoundError(); [HttpPut("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")] public async Task Move(string id, CancellationToken token) { if (await Mine(id, token) is not { } scheduled) return NotFoundError(); if (!DateTime.TryParse(Params.Get("scheduled_at"), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at)) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid"); if (await ScheduledStatuses.Refusal(MyId, at, scheduled.ID, token) is { } refusal) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal); scheduled.ScheduledAt = at; await DB.Default.Update().MatchID(scheduled.ID).Modify(s => s.ScheduledAt, at).ExecuteAsync(token); await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token); return Json(await View(scheduled, _media, token)); } [HttpDelete("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")] public async Task Drop(string id, CancellationToken token) { if (await Mine(id, token) is not { } scheduled) return NotFoundError(); await DB.Default.DeleteAsync(scheduled.ID); // its media were the scheduled post's alone, never posted await _media.Trash(m => m.ScheduledStatusId == scheduled.ID, "scheduled post dropped", token); return Json(new { }); } } }