Skip to content

Commit 2fe023b

Browse files
committed
feat: Add user settings and preferences
- Added repositories and configs - Implemented limit service for prefs - Added middleware providers - Added limit check to PUT handler
1 parent 90edc39 commit 2fe023b

File tree

6 files changed

+479
-11
lines changed

6 files changed

+479
-11
lines changed

lib/src/registry/model_registry.dart

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,58 @@ final modelRegistry = <String, ModelConfig<dynamic>>{
209209
requiresOwnershipCheck: true, // Must be the owner
210210
),
211211
),
212-
// Example for AppSettings (user-owned)
213-
214-
// Add other models following this pattern...
212+
// Configuration for UserAppSettings (user-owned)
213+
'user_app_settings': ModelConfig<UserAppSettings>(
214+
fromJson: UserAppSettings.fromJson,
215+
getId: (s) => s.id,
216+
getOwnerId: (s) => s.id, // User ID is the owner ID
217+
getPermission: const ModelActionPermission(
218+
type: RequiredPermissionType.specificPermission,
219+
permission: Permissions.appSettingsReadOwned,
220+
requiresOwnershipCheck: true,
221+
),
222+
postPermission: const ModelActionPermission(
223+
type: RequiredPermissionType.none,
224+
// Creation of UserAppSettings is handled by the authentication service
225+
// during user creation, not via a direct POST to /api/v1/data.
226+
),
227+
putPermission: const ModelActionPermission(
228+
type: RequiredPermissionType.specificPermission,
229+
permission: Permissions.appSettingsUpdateOwned,
230+
requiresOwnershipCheck: true,
231+
),
232+
deletePermission: const ModelActionPermission(
233+
type: RequiredPermissionType.none,
234+
// Deletion of UserAppSettings is handled by the authentication service
235+
// during account deletion, not via a direct DELETE to /api/v1/data.
236+
),
237+
),
238+
// Configuration for UserContentPreferences (user-owned)
239+
'user_content_preferences': ModelConfig<UserContentPreferences>(
240+
fromJson: UserContentPreferences.fromJson,
241+
getId: (p) => p.id,
242+
getOwnerId: (p) => p.id, // User ID is the owner ID
243+
getPermission: const ModelActionPermission(
244+
type: RequiredPermissionType.specificPermission,
245+
permission: Permissions.userPreferencesReadOwned,
246+
requiresOwnershipCheck: true,
247+
),
248+
postPermission: const ModelActionPermission(
249+
type: RequiredPermissionType.none,
250+
// Creation of UserContentPreferences is handled by the authentication
251+
// service during user creation, not via a direct POST to /api/v1/data.
252+
),
253+
putPermission: const ModelActionPermission(
254+
type: RequiredPermissionType.specificPermission,
255+
permission: Permissions.userPreferencesUpdateOwned,
256+
requiresOwnershipCheck: true,
257+
),
258+
deletePermission: const ModelActionPermission(
259+
type: RequiredPermissionType.none,
260+
// Deletion of UserContentPreferences is handled by the authentication
261+
// service during account deletion, not via a direct DELETE to /api/v1/data.
262+
),
263+
),
215264
};
216265

217266
/// Type alias for the ModelRegistry map for easier provider usage.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import 'package:ht_api/src/services/user_preference_limit_service.dart';
2+
import 'package:ht_data_repository/ht_data_repository.dart';
3+
import 'package:ht_shared/ht_shared.dart';
4+
5+
/// {@template default_user_preference_limit_service}
6+
/// Default implementation of [UserPreferenceLimitService] that enforces limits
7+
/// based on user role and [AppConfig].
8+
/// {@endtemplate}
9+
class DefaultUserPreferenceLimitService implements UserPreferenceLimitService {
10+
/// {@macro default_user_preference_limit_service}
11+
const DefaultUserPreferenceLimitService({
12+
required HtDataRepository<AppConfig> appConfigRepository,
13+
required HtDataRepository<UserContentPreferences>
14+
userContentPreferencesRepository,
15+
}) : _appConfigRepository = appConfigRepository,
16+
_userContentPreferencesRepository = userContentPreferencesRepository;
17+
18+
final HtDataRepository<AppConfig> _appConfigRepository;
19+
final HtDataRepository<UserContentPreferences>
20+
_userContentPreferencesRepository;
21+
22+
// Assuming a fixed ID for the AppConfig document
23+
static const String _appConfigId = 'app_config';
24+
25+
@override
26+
Future<void> checkAddItem(
27+
User user,
28+
String itemType,
29+
int currentCount,
30+
) async {
31+
try {
32+
// 1. Fetch the application configuration to get limits
33+
final appConfig = await _appConfigRepository.read(id: _appConfigId);
34+
final limits = appConfig.userPreferenceLimits;
35+
36+
// 2. Determine the limit based on user role and item type
37+
int limit;
38+
switch (user.role) {
39+
case UserRole.guestUser:
40+
if (itemType == 'headline') {
41+
limit = limits.guestSavedHeadlinesLimit;
42+
} else {
43+
// Applies to countries, sources, categories
44+
limit = limits.guestFollowedItemsLimit;
45+
}
46+
case UserRole.standardUser:
47+
if (itemType == 'headline') {
48+
limit = limits.authenticatedSavedHeadlinesLimit;
49+
} else {
50+
// Applies to countries, sources, categories
51+
limit = limits.authenticatedFollowedItemsLimit;
52+
}
53+
case UserRole.admin:
54+
// Admins have no limits
55+
return;
56+
// Add premium user case when implemented
57+
// case UserRole.premiumUser:
58+
// if (itemType == 'headline') {
59+
// limit = limits.premiumSavedHeadlinesLimit;
60+
// } else {
61+
// limit = limits.premiumFollowedItemsLimit;
62+
// }
63+
}
64+
65+
// 3. Check if adding the item would exceed the limit
66+
if (currentCount >= limit) {
67+
throw ForbiddenException(
68+
'You have reached the maximum number of $itemType items allowed '
69+
'for your account type (${user.role.name}).',
70+
);
71+
}
72+
} on HtHttpException {
73+
// Propagate known exceptions from repositories
74+
rethrow;
75+
} catch (e) {
76+
// Catch unexpected errors
77+
print(
78+
'Error checking limit for user ${user.id}, itemType $itemType: $e',
79+
);
80+
throw OperationFailedException(
81+
'Failed to check user preference limits.',
82+
);
83+
}
84+
}
85+
86+
@override
87+
Future<void> checkUpdatePreferences(
88+
User user,
89+
UserContentPreferences updatedPreferences,
90+
) async {
91+
try {
92+
// 1. Fetch the application configuration to get limits
93+
final appConfig = await _appConfigRepository.read(id: _appConfigId);
94+
final limits = appConfig.userPreferenceLimits;
95+
96+
// 2. Determine limits based on user role
97+
int followedItemsLimit;
98+
int savedHeadlinesLimit;
99+
100+
switch (user.role) {
101+
case UserRole.guestUser:
102+
followedItemsLimit = limits.guestFollowedItemsLimit;
103+
savedHeadlinesLimit = limits.guestSavedHeadlinesLimit;
104+
case UserRole.standardUser:
105+
followedItemsLimit = limits.authenticatedFollowedItemsLimit;
106+
savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit;
107+
case UserRole.admin:
108+
// Admins have no limits
109+
return;
110+
// Add premium user case when implemented
111+
// case UserRole.premiumUser:
112+
// followedItemsLimit = limits.premiumFollowedItemsLimit;
113+
// savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit;
114+
}
115+
116+
// 3. Check if proposed preferences exceed limits
117+
if (updatedPreferences.followedCountries.length > followedItemsLimit) {
118+
throw ForbiddenException(
119+
'You have reached the maximum number of followed countries allowed '
120+
'for your account type (${user.role.name}).',
121+
);
122+
}
123+
if (updatedPreferences.followedSources.length > followedItemsLimit) {
124+
throw ForbiddenException(
125+
'You have reached the maximum number of followed sources allowed '
126+
'for your account type (${user.role.name}).',
127+
);
128+
}
129+
if (updatedPreferences.followedCategories.length > followedItemsLimit) {
130+
throw ForbiddenException(
131+
'You have reached the maximum number of followed categories allowed '
132+
'for your account type (${user.role.name}).',
133+
);
134+
}
135+
if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) {
136+
throw ForbiddenException(
137+
'You have reached the maximum number of saved headlines allowed '
138+
'for your account type (${user.role.name}).',
139+
);
140+
}
141+
} on HtHttpException {
142+
// Propagate known exceptions from repositories
143+
rethrow;
144+
} catch (e) {
145+
// Catch unexpected errors
146+
print(
147+
'Error checking update limits for user ${user.id}: $e',
148+
);
149+
throw OperationFailedException(
150+
'Failed to check user preference update limits.',
151+
);
152+
}
153+
}
154+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import 'package:ht_shared/ht_shared.dart';
2+
3+
/// {@template user_preference_limit_service}
4+
/// Service responsible for enforcing user preference limits based on user role.
5+
/// {@endtemplate}
6+
abstract class UserPreferenceLimitService {
7+
/// {@macro user_preference_limit_service}
8+
const UserPreferenceLimitService();
9+
10+
/// Checks if the user is allowed to add an item of the given type,
11+
/// considering their current count of that item type and their role.
12+
///
13+
/// - [user]: The authenticated user.
14+
/// - [itemType]: The type of item being added (e.g., 'country', 'source',
15+
/// 'category', 'headline').
16+
/// - [currentCount]: The current number of items of this type the user has.
17+
///
18+
/// Throws [ForbiddenException] if adding the item would exceed the user's
19+
/// limit for their role.
20+
Future<void> checkAddItem(User user, String itemType, int currentCount);
21+
22+
/// Checks if the proposed [updatedPreferences] for the user exceed
23+
/// the limits based on their role.
24+
///
25+
/// - [user]: The authenticated user.
26+
/// - [updatedPreferences]: The proposed [UserContentPreferences] object.
27+
///
28+
/// Throws [ForbiddenException] if any list within the preferences exceeds
29+
/// the user's limit for their role.
30+
Future<void> checkUpdatePreferences(
31+
User user,
32+
UserContentPreferences updatedPreferences,
33+
);
34+
}

routes/_middleware.dart

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import 'package:ht_api/src/rbac/permission_service.dart'; // Import PermissionSe
77
import 'package:ht_api/src/registry/model_registry.dart';
88
import 'package:ht_api/src/services/auth_service.dart';
99
import 'package:ht_api/src/services/auth_token_service.dart';
10+
import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; // Import DefaultUserPreferenceLimitService
1011
import 'package:ht_api/src/services/jwt_auth_token_service.dart';
1112
import 'package:ht_api/src/services/token_blacklist_service.dart';
13+
import 'package:ht_api/src/services/user_preference_limit_service.dart'; // Import UserPreferenceLimitService interface
1214
import 'package:ht_api/src/services/verification_code_storage_service.dart';
1315
import 'package:ht_app_settings_client/ht_app_settings_client.dart';
1416
import 'package:ht_app_settings_inmemory/ht_app_settings_inmemory.dart';
@@ -19,6 +21,7 @@ import 'package:ht_email_repository/ht_email_repository.dart';
1921
import 'package:ht_shared/ht_shared.dart';
2022
import 'package:uuid/uuid.dart';
2123

24+
2225
// --- Request ID Wrapper ---
2326

2427
/// {@template request_id}
@@ -154,6 +157,47 @@ HtDataRepository<Country> _createCountryRepository() {
154157
return HtDataRepository<Country>(dataClient: client);
155158
}
156159

160+
// New repositories for user settings and preferences
161+
HtDataRepository<UserAppSettings> _createUserAppSettingsRepository() {
162+
print('Initializing UserAppSettings Repository...');
163+
final client = HtDataInMemoryClient<UserAppSettings>(
164+
toJson: (i) => i.toJson(),
165+
getId: (i) => i.id,
166+
// User settings are created on demand, no initial fixture needed
167+
);
168+
print('UserAppSettings Repository Initialized.');
169+
return HtDataRepository<UserAppSettings>(dataClient: client);
170+
}
171+
172+
HtDataRepository<UserContentPreferences>
173+
_createUserContentPreferencesRepository() {
174+
print('Initializing UserContentPreferences Repository...');
175+
final client = HtDataInMemoryClient<UserContentPreferences>(
176+
toJson: (i) => i.toJson(),
177+
getId: (i) => i.id,
178+
// User preferences are created on demand, no initial fixture needed
179+
);
180+
print('UserContentPreferences Repository Initialized.');
181+
return HtDataRepository<UserContentPreferences>(dataClient: client);
182+
}
183+
184+
HtDataRepository<AppConfig> _createAppConfigRepository() {
185+
print('Initializing AppConfig Repository...');
186+
// AppConfig should have a single instance, potentially loaded from a file
187+
// or created with defaults if not found. For in-memory, we can create a
188+
// default instance.
189+
final initialData = [
190+
const AppConfig(id: 'app_config'), // Default config
191+
];
192+
final client = HtDataInMemoryClient<AppConfig>(
193+
toJson: (i) => i.toJson(),
194+
getId: (i) => i.id,
195+
initialData: initialData,
196+
);
197+
print('AppConfig Repository Initialized.');
198+
return HtDataRepository<AppConfig>(dataClient: client);
199+
}
200+
157201
// --- Middleware Definition ---
158202
Handler middleware(Handler handler) {
159203
// Initialize repositories when middleware is first created
@@ -162,6 +206,11 @@ Handler middleware(Handler handler) {
162206
final categoryRepository = _createCategoryRepository();
163207
final sourceRepository = _createSourceRepository();
164208
final countryRepository = _createCountryRepository();
209+
final userSettingsRepository = _createUserAppSettingsRepository(); // New
210+
final userContentPreferencesRepository =
211+
_createUserContentPreferencesRepository(); // New
212+
final appConfigRepository = _createAppConfigRepository(); // New
213+
165214
final settingsClientImpl = HtAppSettingsInMemory();
166215
const uuid = Uuid();
167216

@@ -208,6 +257,13 @@ Handler middleware(Handler handler) {
208257
const permissionService =
209258
PermissionService(); // Instantiate PermissionService
210259

260+
// --- User Preference Limit Service --- // New
261+
final userPreferenceLimitService = DefaultUserPreferenceLimitService(
262+
appConfigRepository: appConfigRepository,
263+
userContentPreferencesRepository: userContentPreferencesRepository,
264+
);
265+
print('[MiddlewareSetup] DefaultUserPreferenceLimitService instantiated.');
266+
211267
// ==========================================================================
212268
// MIDDLEWARE CHAIN
213269
// ==========================================================================
@@ -263,6 +319,22 @@ Handler middleware(Handler handler) {
263319
(_) => emailRepository,
264320
),
265321
) // Used by AuthService
322+
// New Repositories for User Settings and Preferences
323+
.use(
324+
provider<HtDataRepository<UserAppSettings>>(
325+
(_) => userSettingsRepository,
326+
),
327+
)
328+
.use(
329+
provider<HtDataRepository<UserContentPreferences>>(
330+
(_) => userContentPreferencesRepository,
331+
),
332+
)
333+
.use(
334+
provider<HtDataRepository<AppConfig>>(
335+
(_) => appConfigRepository,
336+
),
337+
)
266338

267339
// --- 4. Authentication Service Providers (Auth Logic Dependencies) ---
268340
// PURPOSE: Provide the core services needed for authentication logic.
@@ -301,15 +373,25 @@ Handler middleware(Handler handler) {
301373
// (e.g., authorizationMiddleware).
302374
.use(provider<PermissionService>((_) => permissionService))
303375

304-
// --- 6. Request Logger (Logging) ---
376+
// --- 6. User Preference Limit Service Provider --- // New
377+
// PURPOSE: Provides the service for enforcing user preference limits.
378+
// ORDER: Must be provided before any handlers that use it (specifically
379+
// the generic data route handlers for UserContentPreferences).
380+
.use(
381+
provider<UserPreferenceLimitService>(
382+
(_) => userPreferenceLimitService,
383+
),
384+
)
385+
386+
// --- 7. Request Logger (Logging) ---
305387
// PURPOSE: Logs details about the incoming request and outgoing response.
306388
// ORDER: Often placed late in the request phase / early in the response
307389
// phase. Placing it here logs the request *before* the handler
308390
// runs and the response *after* the handler (and error handler)
309391
// completes. Can access `RequestId` and potentially `User?`.
310392
.use(requestLogger())
311393

312-
// --- 7. Error Handler (Catch-All) ---
394+
// --- 8. Error Handler (Catch-All) ---
313395
// PURPOSE: Catches exceptions thrown by upstream middleware or route
314396
// handlers and converts them into standardized JSON error responses.
315397
// ORDER: MUST be placed *late* in the chain (typically last before the

0 commit comments

Comments
 (0)