Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Famick.HomeManagement.Domain.Enums;

namespace Famick.HomeManagement.Core.DTOs.Notifications;

/// <summary>
/// A single future-dated reminder returned by the "upcoming reminders" prefetch feed.
/// The mobile app fetches these while online (self-hosted mode) and hands each one to the
/// native OS scheduler (iOS <c>UNCalendarNotificationTrigger</c> / Android <c>AlarmManager</c>)
/// so it fires locally at <see cref="FireAtUtc"/> even with no network — the "download once,
/// alert anytime" strategy that replaces cloud push on self-hosted servers.
/// </summary>
/// <param name="Key">Stable dedup key so the client can diff against what it has already scheduled.</param>
/// <param name="Type">The notification type this reminder represents.</param>
/// <param name="FireAtUtc">When the local notification should fire (UTC).</param>
/// <param name="Title">Notification title.</param>
/// <param name="Body">Notification body.</param>
/// <param name="DeepLinkUrl">Optional deep link to open when the notification is tapped.</param>
/// <param name="ContentHash">Hash of the rendered content so the client can detect changes and reschedule.</param>
public record UpcomingReminderDto(
string Key,
MessageType Type,
DateTime FireAtUtc,
string Title,
string Body,
string? DeepLinkUrl,
string ContentHash);
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ Task DismissAsync(
/// <summary>
/// Creates a new in-app notification
/// </summary>
Task CreateNotificationAsync(
/// <summary>
/// Creates an in-app notification and returns its id (so callers such as the push transport can
/// reference the exact notification, e.g. to mark it read when the OS notification is dismissed).
/// </summary>
Task<Guid> CreateNotificationAsync(
Guid userId,
Guid tenantId,
MessageType type,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Famick.HomeManagement.Core.Interfaces;

/// <summary>
/// Sends silent push notifications that tell a tenant's devices to refresh their locally-scheduled
/// reminders (the offline notification engine). Fired when reminder-relevant data changes (e.g. a
/// calendar event is created/edited/deleted) so the device re-pulls <c>/api/v1/notifications/upcoming</c>
/// and reschedules — giving cloud users real-time freshness without a visible push.
///
/// Cloud provides the real APNs/FCM silent-push implementation; self-hosted uses a no-op
/// (<c>NullReminderSyncPushService</c>) and relies on the periodic prefetch instead.
/// </summary>
public interface IReminderSyncPushService
{
/// <summary>
/// Silently nudges every device in the tenant to re-sync its scheduled reminders.
/// </summary>
Task NotifyRemindersChangedAsync(Guid tenantId, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Famick.HomeManagement.Core.DTOs.Notifications;

namespace Famick.HomeManagement.Core.Interfaces;

/// <summary>
/// Produces future-dated reminders for a single user so a client can pre-schedule them as
/// local OS notifications. Unlike <see cref="INotificationEvaluator"/> (which produces
/// "fire now" items for the daily/calendar background jobs to dispatch through the message
/// pipeline), this projects reminders <b>forward</b> in time with an explicit fire timestamp.
///
/// It is the server half of the self-hosted offline notification engine: a self-hosted server
/// has no push transport, so the mobile app bulk-fetches these while it has connectivity and
/// hands them to the device to fire offline.
/// </summary>
public interface IUpcomingReminderService
{
/// <summary>
/// Returns the reminders that should fire for <paramref name="userId"/> within the next
/// <paramref name="days"/> days, ordered by fire time. Respects the user's per-type
/// <c>PushEnabled</c> preference (local reminders are the self-hosted stand-in for push).
/// </summary>
Task<IReadOnlyList<UpcomingReminderDto>> GetUpcomingAsync(
Guid tenantId,
Guid userId,
int days,
CancellationToken cancellationToken = default);
}
17 changes: 17 additions & 0 deletions src/Famick.HomeManagement.Domain/Enums/MessageType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,21 @@ public static class MessageTypeExtensions
/// Returns true for transactional types (100+) that bypass preferences and are email-only.
/// </summary>
public static bool IsTransactional(this MessageType type) => (int)type >= 100;

/// <summary>
/// Returns true for notification types the mobile app schedules as local OS notifications
/// (future-dated / knowable in advance). These are delivered by the on-device scheduler — fed by
/// the <c>/api/v1/notifications/upcoming</c> feed and silent <c>reminderSync</c> pushes — rather
/// than by visible APNs/FCM push, so cloud push suppresses them to avoid double-delivery.
/// Must stay in sync with the set produced by the upcoming-reminder service.
/// Event-driven types (e.g. <see cref="MessageType.NewFeatures"/>) and transactional types return false.
/// </summary>
public static bool IsLocallySchedulable(this MessageType type) => type switch
{
MessageType.CalendarReminder => true,
MessageType.Expiry => true,
MessageType.LowStock => true,
MessageType.TaskSummary => true,
_ => false
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,18 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
// Register no-op contact sync push service (cloud overrides with real implementation)
services.AddSingleton<IContactSyncPushService, NullContactSyncPushService>();

// Register no-op reminder sync push service (cloud overrides with real silent-push implementation)
services.AddSingleton<IReminderSyncPushService, NullReminderSyncPushService>();


// Register notification services
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<INotificationEvaluator, ExpiryEvaluator>();
services.AddScoped<INotificationEvaluator, LowStockEvaluator>();
services.AddScoped<INotificationEvaluator, TaskSummaryEvaluator>();
services.AddScoped<INotificationEvaluator, CalendarEventEvaluator>();
// Future-dated reminder feed for the mobile offline notification engine (self-hosted mode).
services.AddScoped<IUpcomingReminderService, UpcomingReminderService>();
services.AddSingleton<IDistributedLockService, NoOpDistributedLockService>();

// Register unified messaging service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public async Task DismissAsync(
await _db.SaveChangesAsync(cancellationToken);
}

public async Task CreateNotificationAsync(
public async Task<Guid> CreateNotificationAsync(
Guid userId,
Guid tenantId,
MessageType type,
Expand All @@ -130,6 +130,7 @@ public async Task CreateNotificationAsync(
{
var notification = new Notification
{
Id = Guid.NewGuid(),
UserId = userId,
TenantId = tenantId,
Type = type,
Expand All @@ -141,6 +142,8 @@ public async Task CreateNotificationAsync(

_db.Notifications.Add(notification);
await _db.SaveChangesAsync(cancellationToken);

return notification.Id;
}

public async Task<string?> GetLastContentHashAsync(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Famick.HomeManagement.Core.Interfaces;

namespace Famick.HomeManagement.Infrastructure.Services;

/// <summary>
/// No-op implementation for self-hosted deployments that have no push notification capability.
/// The cloud deployment overrides this with a real silent-push implementation.
/// Self-hosted devices refresh their scheduled reminders via the periodic prefetch instead.
/// </summary>
public class NullReminderSyncPushService : IReminderSyncPushService
{
public Task NotifyRemindersChangedAsync(Guid tenantId, CancellationToken ct = default) => Task.CompletedTask;
}
Loading