File Coverage

blib/lib/Concierge/Users.pm
Criterion Covered Total %
statement 148 150 98.6
branch 72 88 81.8
condition 33 44 75.0
subroutine 13 13 100.0
pod 7 7 100.0
total 273 302 90.4


line stmt bran cond sub pod time code
1             package Concierge::Users v0.8.0;
2 8     8   1791730 use v5.36;
  8         33  
3              
4 8     8   39 use Carp qw/ croak carp /;
  8         25  
  8         480  
5 8     8   5444 use JSON::PP qw/ encode_json decode_json /;
  8         135217  
  8         809  
6 8     8   64 use File::Path qw/ make_path /;
  8         17  
  8         447  
7              
8 8     8   49 use parent qw/ Concierge::Users::Meta /;
  8         12  
  8         62  
9              
10             # ==============================================================================
11             # Setup Method - One-time configuration
12             # ==============================================================================
13              
14             sub setup {
15 69     69 1 1420334 my ($class, $config) = @_;
16              
17 69 100       557 croak "Configuration must be a hash reference"
18             unless ref $config eq 'HASH';
19              
20             # storage_dir is required - validate/create it FIRST before backend operations
21             my $storage_dir = $config->{storage_dir}
22 68 100       771 or croak "Configuration must include 'storage_dir' parameter";
23 66 100       1508 unless (-d $storage_dir) {
24 15         53 eval { make_path($storage_dir) };
  15         3819  
25 15 50       89 croak "Cannot create storage directory: $storage_dir\nError: $@" if $@;
26             }
27              
28             # Explicit backend selection is required
29             my $backend_type = $config->{backend}
30 66 100       555 or croak "Configuration must include 'backend' parameter 'database', 'file', or 'yaml'";
31              
32             # Normalize backend name and determine module name
33 64         121 my $backend = do {
34 64         219 my $back = lc $backend_type;
35 64 100       661 $back eq 'database' ? 'Database' :
    100          
    100          
36             $back eq 'file' ? 'File' :
37             $back eq 'yaml' ? 'YAML' :
38             croak "Invalid backend type: $backend_type (must be 'database', 'file', or 'yaml')";
39             };
40              
41             # Load backend module
42 62         173 my $backend_class = "Concierge::Users::${backend}";
43 62         7280 eval "require $backend_class";
44             return {
45 62 50       1813 success => 0,
46             message => "Backend '$backend_type' not available: $@"
47             } if $@;
48              
49 62         427 my $field_meta = Concierge::Users::Meta::init_field_meta($config);
50              
51             # Merge original config with field_meta for backend configure()
52             # (backend needs storage_dir and other config options)
53 62         355 my $backend_config = {
54             %$config,
55             %$field_meta,
56             };
57              
58             # Call backend configure() to create storage
59 62         510 my $configure_result = $backend_class->configure( $backend_config );
60 62 100       402 return $configure_result unless $configure_result->{success};
61              
62             # Config file is always: storage_dir/users-config.json
63 60         258 my $config_file = "$storage_dir/users-config.json";
64              
65             # Build complete config structure for serialization
66             my $config_to_save = {
67             version => "$Concierge::Users::VERSION",
68             generated => Concierge::Users::Meta->current_timestamp(),
69             backend_module => "Concierge::Users::${backend}",
70             backend_config => $configure_result->{config},
71             fields => $field_meta->{fields},
72             field_definitions => $field_meta->{field_definitions},
73 60         1275 storage_initialized => 1,
74             };
75              
76             # Serialize and save JSON config
77 60         208 eval {
78 60 50       10951 open my $fh, '>', $config_file or croak "Cannot open $config_file for writing: $!";
79 60         240 print {$fh} encode_json($config_to_save);
  60         566  
80 60         756922 close $fh;
81             };
82             return {
83 60 50       377 success => 0,
84             message => "Failed to write config file: $config_file\nError: $@"
85             } if $@;
86              
87             # Generate and save YAML config (human-readable reference)
88 60         226 my $yaml_file = "$storage_dir/users-config.yaml";
89 60         465 my $yaml_content = Concierge::Users::Meta::config_to_yaml($config_to_save, $storage_dir);
90 60         158 eval {
91 60 50       10925 open my $fh, '>', $yaml_file or croak "Cannot open $yaml_file for writing: $!";
92 60         224 print {$fh} $yaml_content;
  60         1825  
93 60         2796 close $fh;
94 60         2360 chmod 0666, $yaml_file; # Writable - allows setup() to overwrite
95             };
96             return {
97 60 50       267 success => 0,
98             message => "Failed to write YAML config file: $yaml_file\nError: $@"
99             } if $@;
100              
101             return {
102 60         2990 success => 1,
103             message => "Users system configured successfully",
104             config_file => $config_file,
105             yaml_file => $yaml_file,
106             };
107             }
108              
109             # ==============================================================================
110             # Constructor - Load from saved config
111             # ==============================================================================
112              
113             sub new {
114 65     65 1 165139 my ($class, $config_file) = @_;
115              
116 65 100 100     1891 croak "Usage: Concierge::Users->new('/path/to/users-config.json')"
117             . "\nCall Concierge::Users->setup() first with configuration to create the config file"
118             unless $config_file && -f $config_file;
119              
120             # Load and deserialize config
121 63         167 my $config_json;
122 63         179 eval {
123 63 50       2440 open my $fh, '<', $config_file or croak "Cannot open $config_file: $!";
124 63         431 local $/; # slurp mode
125 63         2457 $config_json = <$fh>;
126 63         1034 close $fh;
127             };
128 63 50       244 croak "Failed to read config file: $config_file\nError: $@" if $@;
129              
130 63         125 my $saved_config;
131 63         127 eval {
132 63         404 $saved_config = decode_json($config_json);
133             };
134 63 100       4105933 croak "Failed to parse config file: $config_file\nError: $@" if $@;
135              
136             # Validate config structure
137             croak "Invalid config file: missing 'backend_module' or 'fields'"
138 62 100 66     755 unless $saved_config->{backend_module} && $saved_config->{fields};
139              
140             # Load backend module
141 61         213 my $backend_module = $saved_config->{backend_module};
142 61         7567 eval "require $backend_module";
143 61 50       411 croak "Backend '$backend_module' not available: $@" if $@;
144              
145             # Instantiate backend with its config (no fields needed for runtime)
146 61         577 my $backend_obj = $backend_module->new($saved_config->{backend_config});
147              
148             # Create Users object - just store what's needed for API operations
149             my $self = bless {
150             backend => $backend_obj,
151             fields => $saved_config->{fields},
152             field_definitions => $saved_config->{field_definitions},
153 61         549 }, $class;
154              
155 61         674 return $self;
156             }
157              
158             # ==============================================================================
159             # Public API Methods
160             # ==============================================================================
161              
162             # Register a new user
163             sub register_user {
164 130     130 1 56834 my ($self, $user_data) = @_;
165              
166 130 100       733 return { success => 0, message => "User data must be a hash reference" }
167             unless ref $user_data eq 'HASH';
168              
169             # Clone user_data to avoid modifying caller's hashref
170 128         622 my $data = { %$user_data };
171              
172             # 0. Clean $data
173             # Delete any data for system timestamps
174 128         700 delete $data->{$_} for qw/created_date last_mod_date/;
175             # Define undefined values and remove leading and trailing whitespace
176 128         626 for my $f (keys $data->%*) {
177 417   100     1164 $data->{$f} //= '';
178 417         3846 $data->{$f} =~ s/^\s*|\s*$//g;
179             }
180              
181             # 1. Validate user_id, including allowing email address as ID
182             return { success => 0, message => "user_id is required as 2-30 characters, email OK, no spaces" }
183             unless $data->{user_id}
184 128 50 33     1462 && $data->{user_id} =~ /^[a-zA-Z0-9._@-]{2,30}$/;
185              
186             # 2. Validate moniker
187             return { success => 0, message => "moniker is required as 2-24 alphanumeric characters, no spaces" }
188             unless $data->{moniker}
189 128 100 100     1310 && $data->{moniker} =~ /^[a-zA-Z0-9]{2,24}$/;
190              
191             # 3. Check if user already exists
192 125         679 my $existing = $self->get_user($data->{user_id});
193             return { success => 0, message => "User '$data->{user_id}' already exists" }
194 125 100       488 if $existing->{success};
195              
196             # 4. Store user_id and moniker, then remove from data for further processing
197 123         390 my $new_user_id = delete $data->{user_id};
198             my $user_init_record = {
199             user_id => $new_user_id,
200             moniker => delete $data->{moniker},
201 123         572 };
202 123         257 for my $field (@{$self->{fields}}) {
  123         553  
203             # Skip user_id and moniker - already set
204 1044 100 100     3666 next if $field eq 'user_id' || $field eq 'moniker';
205              
206             # Get field definition
207 798         1850 my $def = $self->{field_definitions}->{$field};
208             # Apply default for new records if it is defined
209 798 50       1835 if (defined $def->{default}) {
    0          
210 798         2444 $user_init_record->{$field} = $def->{default};
211             }
212             # Otherwise apply null_value for record initialization
213             elsif (defined $def->{null_value}) {
214 0         0 $user_init_record->{$field} = $def->{null_value};
215             }
216             else {
217 0         0 $user_init_record->{$field} = '';
218             }
219             }
220 123         780 my $result = $self->{backend}->add( $new_user_id, $user_init_record );
221 123 50       759 return $result unless $result->{success};
222              
223             # 5. Validate
224 123         1300 my $validation = $self->validate_user_data( $data );
225 123 100       598 return $validation unless $validation->{success};
226             # Proceed only with validated data
227 120         358 my $validated_user_data = $validation->{valid_data};
228              
229             # 6. Populate the record with validated user data
230 120         952 $result = $self->{backend}->update( $new_user_id, $validated_user_data );
231              
232             # Override message to indicate creation rather than update
233 120         686 $result->{message} = "User '$new_user_id' created";
234              
235             # Add warnings to result if any
236 120 100       674 $result->{warnings} = $validation->{warnings} if $validation->{warnings};
237              
238 120         1973 return $result;
239             }
240              
241             # Get user by ID
242             sub get_user {
243 204     204 1 30487 my ($self, $user_id, $options) = @_;
244              
245 204 100 100     1515 return { success => 0, message => "user_id is required" }
246             unless $user_id && $user_id =~ /\S/;
247              
248 201   100     1106 $options ||= {};
249              
250 201         1291 my $fetch_result = $self->{backend}->fetch($user_id);
251              
252 201 100       1039 unless ($fetch_result->{success}) {
253 137         929 return { success => 0, message => $fetch_result->{message} };
254             }
255              
256 64         210 my $user_data = $fetch_result->{data};
257              
258             # Handle field selection
259 64 100 66     291 if ($options->{fields} && ref $options->{fields} eq 'ARRAY') {
260 3         9 my %selected;
261 3         7 $selected{$_} = $user_data->{$_} for @{$options->{fields}};
  3         22  
262 3         10 $selected{user_id} = $user_data->{user_id}; # Always include user_id
263 3         10 $user_data = \%selected;
264             }
265              
266             return {
267 64         493 success => 1,
268             user_id => $user_id,
269             user => $user_data
270             };
271             }
272              
273             # Update user
274             sub update_user {
275 21     21 1 11407 my ($self, $user_id, $updates) = @_;
276              
277 21 100 66     243 return { success => 0, message => "user_id is required" }
278             unless $user_id && $user_id =~ /\S/;
279              
280 20 100       90 return { success => 0, message => "Updates must be a hash reference" }
281             unless ref $updates eq 'HASH';
282              
283             # Check if user exists
284 18         75 my $existing = $self->get_user($user_id);
285 18 100       77 unless ($existing->{success}) {
286 4         30 return { success => 0, message => "User '$user_id' not found" };
287             }
288              
289             # 0. Clean $updates
290             # Delete any data for user_id and system timestamps
291 14         81 delete $updates->{$_} for qw/user_id created_date last_mod_date/;
292             # Define undefined values and remove leading and trailing whitespace
293 14         52 for my $f (keys $updates->%*) {
294 19   50     66 $updates->{$f} //= '';
295 19         274 $updates->{$f} =~ s/^\s*|\s*$//g;
296             }
297              
298             # 1. Validate
299 14         94 my $validation = $self->validate_user_data( $updates );
300 14 100       65 return $validation unless $validation->{success};
301             # Proceed only with validated data
302 13         54 my $validated_updates = $validation->{valid_data};
303              
304             # 2. Populate the record with user data
305 13         67 my $result = $self->{backend}->update( $user_id, $validated_updates );
306            
307             # Add warnings to result if any
308 13 100       81 if ($validation->{warnings}) {
309 1         3 $result->{warnings} = $validation->{warnings};
310             }
311              
312 13         180 return $result;
313             }
314              
315             # List users - only returns user_ids with optional filtering
316             sub list_users {
317 17     17 1 8199 my ($self, $filter_string) = @_;
318              
319             # Parse filter string if provided
320 17         58 my $filters = {};
321 17 100 66     112 if ($filter_string && $filter_string =~ /\S/) {
322 5         55 $filters = $self->parse_filter_string($filter_string);
323             }
324              
325 17         133 my $users = $self->{backend}->list($filters, {});
326 17 50       63 my @user_ids = map { $_->{user_id} } @{$users->{data} || []};
  81         289  
  17         89  
327              
328             return {
329             success => 1,
330             user_ids => \@user_ids,
331 17 100 100     391 total_count => $users->{total_count} || 0,
      66        
332             filter_applied => ($filter_string && $filter_string =~ /\S/) ? $filter_string : '',
333             };
334             }
335              
336             # Delete user
337             sub delete_user {
338 10     10 1 3401 my ($self, $user_id) = @_;
339              
340 10 100 66     107 return { success => 0, message => "user_id is required" }
341             unless $user_id && $user_id =~ /\S/;
342              
343             # Check if user exists
344 8         31 my $existing = $self->get_user($user_id);
345 8 100       37 unless ($existing->{success}) {
346 3         20 return { success => 0, message => "User '$user_id' not found" };
347             }
348              
349             # Delete using backend
350 5         59 my $result = $self->{backend}->delete($user_id);
351              
352 5         51 return $result;
353             }
354              
355             # Utility methods
356              
357             # Cleanup
358             sub DESTROY {
359 61     61   52522 my $self = shift;
360              
361             # Disconnect backend if it has a disconnect method
362 61 50 33     919 if ($self->{backend} && $self->{backend}->can('disconnect')) {
363 61         365 $self->{backend}->disconnect();
364             }
365             }
366              
367             1;
368              
369             __END__