Skip to content

Commit c07b782

Browse files
committed
feat: Implemented a feed enhancement service, it should now be capable of taking a list of primary items (like headlines) and intelligently injecting ads, engagement prompts, and suggested content based on the rules in AppConfig and the user's context.
1 parent 0d3097e commit c07b782

10 files changed

+716
-3
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import 'package:ht_api/src/feed_enhancement/feed_decorator.dart';
2+
import 'package:ht_api/src/feed_enhancement/feed_enhancement_context.dart';
3+
import 'package:ht_shared/ht_shared.dart';
4+
import 'package:uuid/uuid.dart';
5+
6+
/// {@template ad_decorator}
7+
/// A [FeedDecorator] that injects [Ad] items into the feed based on
8+
/// [AppConfig.adConfig] and the user's role.
9+
///
10+
/// This decorator constructs [Ad] objects as indicators for the client
11+
/// application to render actual ads via its SDK.
12+
/// {@endtemplate}
13+
class AdDecorator implements FeedDecorator {
14+
/// {@macro ad_decorator}
15+
const AdDecorator({Uuid? uuidGenerator}) : _uuid = uuidGenerator ?? const Uuid();
16+
17+
final Uuid _uuid;
18+
19+
@override
20+
Future<List<FeedItem>> decorate(
21+
List<FeedItem> currentFeedItems,
22+
FeedEnhancementContext context,
23+
) async {
24+
final userRole = context.authenticatedUser.role;
25+
final adConfig = context.appConfig.adConfig;
26+
27+
int adFrequency;
28+
int adPlacementInterval;
29+
30+
switch (userRole) {
31+
case UserRole.guestUser:
32+
adFrequency = adConfig.guestAdFrequency;
33+
adPlacementInterval = adConfig.guestAdPlacementInterval;
34+
case UserRole.standardUser:
35+
adFrequency = adConfig.authenticatedAdFrequency;
36+
adPlacementInterval = adConfig.authenticatedAdPlacementInterval;
37+
case UserRole.premiumUser:
38+
// Premium users typically see no ads
39+
adFrequency = adConfig.premiumAdFrequency;
40+
adPlacementInterval = adConfig.premiumAdPlacementInterval;
41+
case UserRole.admin:
42+
// Admins typically see no ads in the regular feed
43+
return currentFeedItems;
44+
}
45+
46+
// If adFrequency is 0, no ads for this user role.
47+
if (adFrequency <= 0) {
48+
return currentFeedItems;
49+
}
50+
51+
final decoratedFeed = <FeedItem>[];
52+
int primaryItemCount = 0;
53+
int adsInjected = 0;
54+
55+
for (var i = 0; i < currentFeedItems.length; i++) {
56+
decoratedFeed.add(currentFeedItems[i]);
57+
primaryItemCount++;
58+
59+
// Check if it's time to inject an ad
60+
if (primaryItemCount % adFrequency == 0 &&
61+
primaryItemCount >= adPlacementInterval) {
62+
// Construct a placeholder Ad item. The client app will interpret this.
63+
final adItem = Ad(
64+
id: _uuid.v4(),
65+
imageUrl: 'https://example.com/placeholder_ad.png',
66+
targetUrl: 'https://example.com/ad_click_target',
67+
adType: AdType.banner, // Example ad type
68+
placement: AdPlacement.feedInlineStandardBanner, // Example placement
69+
action: const OpenExternalUrl(url: 'https://example.com/ad_click_target'),
70+
);
71+
decoratedFeed.add(adItem);
72+
adsInjected++;
73+
// Reset primaryItemCount to ensure interval is respected after injection
74+
primaryItemCount = 0;
75+
}
76+
}
77+
78+
return decoratedFeed;
79+
}
80+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import 'package:ht_api/src/feed_enhancement/feed_decorator.dart';
2+
import 'package:ht_api/src/feed_enhancement/feed_enhancement_context.dart';
3+
import 'package:ht_data_repository/ht_data_repository.dart';
4+
import 'package:ht_shared/ht_shared.dart';
5+
import 'package:uuid/uuid.dart';
6+
7+
/// {@template engagement_decorator}
8+
/// A [FeedDecorator] that injects [EngagementContent] items into the feed
9+
/// based on [AppConfig.engagementRules] and the user's state.
10+
/// {@endtemplate}
11+
class EngagementDecorator implements FeedDecorator {
12+
/// {@macro engagement_decorator}
13+
const EngagementDecorator({Uuid? uuidGenerator})
14+
: _uuid = uuidGenerator ?? const Uuid();
15+
16+
final Uuid _uuid;
17+
18+
@override
19+
Future<List<FeedItem>> decorate(
20+
List<FeedItem> currentFeedItems,
21+
FeedEnhancementContext context,
22+
) async {
23+
final user = context.authenticatedUser;
24+
final appConfig = context.appConfig;
25+
final userAppSettingsRepository = context.userAppSettingsRepository;
26+
final engagementTemplateRepository =
27+
context.engagementContentTemplateRepository;
28+
29+
final rules = appConfig.engagementRules;
30+
if (rules.isEmpty) {
31+
return currentFeedItems;
32+
}
33+
34+
final decoratedFeed = <FeedItem>[...currentFeedItems];
35+
final now = DateTime.now().toUtc();
36+
37+
// Fetch user app settings to check engagement history
38+
UserAppSettings userAppSettings;
39+
try {
40+
userAppSettings = await userAppSettingsRepository.read(id: user.id);
41+
} on NotFoundException {
42+
// If settings not found, create default ones (should be handled by auth)
43+
// or assume no history for this session. For now, use default.
44+
userAppSettings = UserAppSettings(id: user.id);
45+
} catch (e) {
46+
print('Error fetching UserAppSettings for engagement: $e');
47+
// Fail gracefully, don't inject engagements if settings can't be read
48+
return currentFeedItems;
49+
}
50+
51+
// Keep track of changes to userAppSettings to save them later
52+
var updatedAppSettings = userAppSettings;
53+
var appSettingsUpdated = false;
54+
55+
// Variables to hold the selected rule and template
56+
EngagementRule? ruleToInject;
57+
EngagementContentTemplate? templateToInject;
58+
int selectedRuleShownCount = 0; // Initialize to 0
59+
60+
for (final rule in rules) {
61+
// 1. Check user role
62+
if (!rule.userRoles.contains(user.role)) {
63+
continue;
64+
}
65+
66+
// 2. Check minDaysSinceAccountCreation
67+
if (rule.minDaysSinceAccountCreation != null && user.createdAt != null) {
68+
final daysSinceCreation = now.difference(user.createdAt!).inDays;
69+
if (daysSinceCreation < rule.minDaysSinceAccountCreation!) {
70+
continue;
71+
}
72+
}
73+
74+
// 3. Check maxTimesToShow
75+
final currentShownCount =
76+
updatedAppSettings.engagementShownCounts[rule.templateType.name] ?? 0;
77+
if (rule.maxTimesToShow != null &&
78+
currentShownCount >= rule.maxTimesToShow!) {
79+
continue;
80+
}
81+
82+
// 4. Check minDaysSinceLastShown
83+
final lastShownTimestamp =
84+
updatedAppSettings.engagementLastShownTimestamps[rule.templateType.name];
85+
if (rule.minDaysSinceLastShown != null && lastShownTimestamp != null) {
86+
final daysSinceLastShown = now.difference(lastShownTimestamp).inDays;
87+
if (daysSinceLastShown < rule.minDaysSinceLastShown!) {
88+
continue;
89+
}
90+
}
91+
92+
// 5. Fetch the template content
93+
try {
94+
templateToInject = await engagementTemplateRepository.read(
95+
id: rule.templateType.name,
96+
);
97+
} on NotFoundException {
98+
print(
99+
'Warning: Engagement template "${rule.templateType.name}" not found.',
100+
);
101+
continue; // Skip this rule if template is missing
102+
} catch (e) {
103+
print(
104+
'Error fetching engagement template "${rule.templateType.name}": $e',
105+
);
106+
continue;
107+
}
108+
109+
// If we reach here, this rule is a candidate.
110+
// For simplicity, we'll inject the first valid one found.
111+
ruleToInject = rule;
112+
selectedRuleShownCount = currentShownCount; // Capture for the selected rule
113+
break; // Exit loop after finding the first suitable rule
114+
}
115+
116+
if (ruleToInject != null && templateToInject != null) {
117+
final engagementItem = EngagementContent(
118+
id: _uuid.v4(),
119+
title: templateToInject.title,
120+
description: templateToInject.description,
121+
callToActionText: templateToInject.callToActionText,
122+
engagementContentType:
123+
EngagementContentType.values.byName(ruleToInject.templateType.name),
124+
// Action for the client to perform when this is tapped.
125+
// This could be more dynamic based on templateType or rule.
126+
action: const OpenExternalUrl(url: 'https://example.com/engagement_action'),
127+
);
128+
129+
// Determine placement (simple for now, can be enhanced)
130+
final placement = ruleToInject.placement;
131+
var inserted = false;
132+
133+
if (placement != null) {
134+
if (placement.minPrimaryItemsRequired != null &&
135+
currentFeedItems.length < placement.minPrimaryItemsRequired!) {
136+
// Not enough primary items for this placement, do not insert
137+
} else if (placement.afterPrimaryItemIndex != null &&
138+
placement.afterPrimaryItemIndex! < decoratedFeed.length) {
139+
decoratedFeed.insert(placement.afterPrimaryItemIndex! + 1, engagementItem);
140+
inserted = true;
141+
} else if (placement.relativePosition == 'middle' &&
142+
decoratedFeed.isNotEmpty) {
143+
final middleIndex = (decoratedFeed.length / 2).floor();
144+
decoratedFeed.insert(middleIndex, engagementItem);
145+
inserted = true;
146+
}
147+
}
148+
149+
// If no specific placement or placement failed, append to end (fallback)
150+
if (!inserted) {
151+
decoratedFeed.add(engagementItem);
152+
}
153+
154+
// Update userAppSettings for tracking
155+
updatedAppSettings = updatedAppSettings.copyWith(
156+
engagementShownCounts: {
157+
...updatedAppSettings.engagementShownCounts,
158+
ruleToInject.templateType.name: selectedRuleShownCount + 1,
159+
},
160+
engagementLastShownTimestamps: {
161+
...updatedAppSettings.engagementLastShownTimestamps,
162+
ruleToInject.templateType.name: now,
163+
},
164+
);
165+
appSettingsUpdated = true;
166+
}
167+
168+
// Save updated userAppSettings if changes were made
169+
if (appSettingsUpdated) {
170+
try {
171+
await userAppSettingsRepository.update(
172+
id: updatedAppSettings.id,
173+
item: updatedAppSettings,
174+
userId: user.id,
175+
);
176+
} catch (e) {
177+
print('Error saving updated UserAppSettings for engagement: $e');
178+
// Log error, but don't prevent feed from being returned
179+
}
180+
}
181+
182+
return decoratedFeed;
183+
}
184+
}

0 commit comments

Comments
 (0)