Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/MODS/PTMatrix/CustomFields.pm
Diff

/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/MODS/PTMatrix/CustomFields.pm

added on local at 2026-07-01 12:34:22

Added
+442
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 98b3885b5a1c
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::PTMatrix::CustomFields;
2#======================================================================
3# TaskForge -- Custom fields for tickets/tasks.
4#
5# Two tables back this module (db_schema.sql + _ticketing_migration.sql):
6# custom_field_definitions per-company field schema
7# task_custom_field_values per-task values per field
8#
9# Companies define their own field set (Jira/Redmine-style). Field
10# types: text, textarea, number, date, datetime, checkbox, select,
11# multi_select, url, email, user, currency. Multi-select stores its
12# value as JSON in value_json; everything else uses value_text +
13# value_num + value_date depending on type.
14#
15# Used by:
16# admin_custom_fields.cgi field definition CRUD
17# tickets.cgi ticket queue (custom-field columns)
18# task.cgi ticket detail (custom-field editor)
19# task_action.cgi custom-field value mutations
20#
21# Schema-readiness is checked at every entry point so an unmigrated
22# install renders cleanly (empty fields list) rather than 500-ing.
23#======================================================================
24use strict;
25use warnings;
26
27sub new {
28 my ($class) = @_;
29 return bless({}, $class);
30}
31
32# ---- Schema readiness ------------------------------------------------
33sub schema_ready {
34 my ($self, $db, $dbh, $DB) = @_;
35 foreach my $t (qw(custom_field_definitions task_custom_field_values)) {
36 my $r = $db->db_readwrite($dbh, qq~
37 SELECT COUNT(*) AS n FROM information_schema.tables
38 WHERE table_schema='$DB' AND table_name='$t'
39 ~, $ENV{SCRIPT_NAME}, __LINE__);
40 return 0 unless ($r && $r->{n});
41 }
42 return 1;
43}
44
45sub ALLOWED_TYPES { return qw(text textarea number date datetime checkbox select multi_select url email user currency) }
46
47sub _is_valid_type {
48 my ($type) = @_;
49 return 0 unless defined $type;
50 my %ok = map { $_ => 1 } ALLOWED_TYPES();
51 return $ok{$type} ? 1 : 0;
52}
53
54# ---- Definitions CRUD ------------------------------------------------
55# List all definitions for a company. $active_only = 1 returns only
56# is_active=1 rows. Sorted by sort_order, then label.
57sub list_definitions {
58 my ($self, $db, $dbh, $DB, $company_id, $active_only) = @_;
59 return () unless $self->schema_ready($db, $dbh, $DB);
60 $company_id =~ s/[^0-9]//g;
61 return () unless $company_id;
62 my $where = "company_id='$company_id'";
63 $where .= " AND is_active=1" if $active_only;
64 return $db->db_readwrite_multiple($dbh, qq~
65 SELECT * FROM `${DB}`.custom_field_definitions
66 WHERE $where
67 ORDER BY sort_order ASC, label ASC
68 ~, $ENV{SCRIPT_NAME}, __LINE__);
69}
70
71sub get_definition {
72 my ($self, $db, $dbh, $DB, $field_id) = @_;
73 return undef unless $self->schema_ready($db, $dbh, $DB);
74 $field_id =~ s/[^0-9]//g;
75 return undef unless $field_id;
76 my $r = $db->db_readwrite($dbh, qq~
77 SELECT * FROM `${DB}`.custom_field_definitions
78 WHERE id='$field_id' LIMIT 1
79 ~, $ENV{SCRIPT_NAME}, __LINE__);
80 return ($r && $r->{id}) ? $r : undef;
81}
82
83# Auto-derive a safe field_key from a label if the caller didn't pass one.
84sub _slugify_key {
85 my ($s) = @_;
86 return '' unless defined $s;
87 $s = lc $s;
88 $s =~ s/[^a-z0-9]+/_/g;
89 $s =~ s/^_+|_+$//g;
90 $s = substr($s, 0, 60);
91 return $s;
92}
93
94sub create_definition {
95 my ($self, $db, $dbh, $DB, %a) = @_;
96 return 0 unless $self->schema_ready($db, $dbh, $DB);
97 my $company_id = $a{company_id}; $company_id =~ s/[^0-9]//g;
98 return 0 unless $company_id;
99
100 my $label = defined $a{label} ? $a{label} : '';
101 $label =~ s/^\s+|\s+$//g;
102 $label = substr($label, 0, 120);
103 return 0 unless length $label;
104
105 my $key = $a{field_key} || _slugify_key($label);
106 $key =~ s/[^a-z0-9_]//g;
107 $key = substr($key, 0, 60);
108 return 0 unless length $key;
109
110 my $type = $a{field_type} || 'text';
111 return 0 unless _is_valid_type($type);
112
113 my $scope = $a{scope} || 'all';
114 $scope = 'all' unless $scope =~ /^(all|project|task_type)$/;
115 my $scope_id = $a{scope_id}; $scope_id =~ s/[^0-9]//g if defined $scope_id;
116 my $scope_id_sql = ($scope eq 'all' || !$scope_id) ? 'NULL' : "'$scope_id'";
117
118 my $required = ($a{is_required} && $a{is_required} ne '0') ? 1 : 0;
119 my $active = (defined $a{is_active} && $a{is_active} eq '0') ? 0 : 1;
120 my $show_q = ($a{show_on_queue} && $a{show_on_queue} ne '0') ? 1 : 0;
121 my $show_c = ($a{show_on_card} && $a{show_on_card} ne '0') ? 1 : 0;
122
123 my $sort = defined $a{sort_order} ? $a{sort_order} : 100;
124 $sort =~ s/[^0-9-]//g; $sort = 100 unless length $sort;
125
126 my $actor = $a{actor_user_id}; $actor =~ s/[^0-9]//g if defined $actor;
127 my $actor_sql = ($actor) ? "'$actor'" : 'NULL';
128
129 my $q_label = _q($label);
130 my $q_desc = _q($a{description});
131 my $q_opts = _q($a{options_json});
132 my $q_place = _q($a{placeholder});
133 my $q_help = _q($a{help_text});
134 my $q_default = _q($a{default_value});
135
136 my $r = $db->db_readwrite($dbh, qq~
137 INSERT INTO `${DB}`.custom_field_definitions
138 SET company_id='$company_id',
139 field_key='$key',
140 label=$q_label,
141 description=$q_desc,
142 field_type='$type',
143 options_json=$q_opts,
144 is_required='$required',
145 is_active='$active',
146 scope='$scope',
147 scope_id=$scope_id_sql,
148 sort_order='$sort',
149 placeholder=$q_place,
150 help_text=$q_help,
151 default_value=$q_default,
152 show_on_queue='$show_q',
153 show_on_card='$show_c',
154 created_by_user_id=$actor_sql,
155 created_at=NOW()
156 ~, $ENV{SCRIPT_NAME}, __LINE__);
157 return ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
158}
159
160sub update_definition {
161 my ($self, $db, $dbh, $DB, $field_id, %a) = @_;
162 return 0 unless $self->schema_ready($db, $dbh, $DB);
163 $field_id =~ s/[^0-9]//g;
164 return 0 unless $field_id;
165
166 my @sets;
167 if (exists $a{label}) {
168 my $v = $a{label}; $v =~ s/^\s+|\s+$//g; $v = substr($v, 0, 120);
169 push @sets, 'label=' . _q($v);
170 }
171 if (exists $a{description}) { push @sets, 'description=' . _q($a{description}); }
172 if (exists $a{options_json}) { push @sets, 'options_json=' . _q($a{options_json}); }
173 if (exists $a{placeholder}) { push @sets, 'placeholder=' . _q($a{placeholder}); }
174 if (exists $a{help_text}) { push @sets, 'help_text=' . _q($a{help_text}); }
175 if (exists $a{default_value}) { push @sets, 'default_value=' . _q($a{default_value}); }
176 if (exists $a{field_type}) {
177 my $t = $a{field_type};
178 return 0 unless _is_valid_type($t);
179 push @sets, "field_type='$t'";
180 }
181 if (exists $a{is_required}) { push @sets, 'is_required=' . (($a{is_required} && $a{is_required} ne '0') ? "'1'" : "'0'"); }
182 if (exists $a{is_active}) { push @sets, 'is_active=' . ((defined $a{is_active} && $a{is_active} eq '0') ? "'0'" : "'1'"); }
183 if (exists $a{show_on_queue}) { push @sets, 'show_on_queue=' . (($a{show_on_queue} && $a{show_on_queue} ne '0') ? "'1'" : "'0'"); }
184 if (exists $a{show_on_card}) { push @sets, 'show_on_card=' . (($a{show_on_card} && $a{show_on_card} ne '0') ? "'1'" : "'0'"); }
185 if (exists $a{sort_order}) {
186 my $v = $a{sort_order}; $v =~ s/[^0-9-]//g; $v = 100 unless length $v;
187 push @sets, "sort_order='$v'";
188 }
189 if (exists $a{scope}) {
190 my $s = $a{scope}; $s = 'all' unless $s =~ /^(all|project|task_type)$/;
191 push @sets, "scope='$s'";
192 my $sid = $a{scope_id}; $sid =~ s/[^0-9]//g if defined $sid;
193 push @sets, 'scope_id=' . (($s eq 'all' || !$sid) ? 'NULL' : "'$sid'");
194 }
195 return 0 unless @sets;
196
197 my $set_sql = join(',', @sets);
198 $db->db_readwrite($dbh, qq~
199 UPDATE `${DB}`.custom_field_definitions
200 SET $set_sql
201 WHERE id='$field_id'
202 ~, $ENV{SCRIPT_NAME}, __LINE__);
203 return 1;
204}
205
206sub archive_definition {
207 my ($self, $db, $dbh, $DB, $field_id) = @_;
208 return $self->update_definition($db, $dbh, $DB, $field_id, is_active => 0);
209}
210
211sub delete_definition {
212 my ($self, $db, $dbh, $DB, $field_id) = @_;
213 return 0 unless $self->schema_ready($db, $dbh, $DB);
214 $field_id =~ s/[^0-9]//g;
215 return 0 unless $field_id;
216 # task_custom_field_values has ON DELETE CASCADE so values clean up.
217 $db->db_readwrite($dbh, qq~
218 DELETE FROM `${DB}`.custom_field_definitions WHERE id='$field_id'
219 ~, $ENV{SCRIPT_NAME}, __LINE__);
220 return 1;
221}
222
223# ---- Values --------------------------------------------------------
224# Get every custom-field value for a single task. Returns arrayref of
225# hashrefs with the definition + value (text/json/num/date) merged in
226# so the template can render each row in one loop.
227sub get_values_for_task {
228 my ($self, $db, $dbh, $DB, $task_id, $company_id) = @_;
229 return [] unless $self->schema_ready($db, $dbh, $DB);
230 $task_id =~ s/[^0-9]//g;
231 $company_id =~ s/[^0-9]//g if defined $company_id;
232 return [] unless $task_id;
233
234 # Pull the task's project_id + task_type_id so we can filter scoped
235 # fields. Scoped fields show only when the task's project / type matches.
236 my $t = $db->db_readwrite($dbh, qq~
237 SELECT project_id, task_type_id FROM `${DB}`.tasks WHERE id='$task_id' LIMIT 1
238 ~, $ENV{SCRIPT_NAME}, __LINE__);
239 my $pid = ($t && $t->{project_id}) ? $t->{project_id} : 0;
240 my $tt = ($t && $t->{task_type_id}) ? $t->{task_type_id} : 0;
241
242 my $co_filter = $company_id ? "d.company_id='$company_id' AND" : '';
243 my $scope_clause = qq~AND (
244 d.scope='all'
245 OR (d.scope='project' AND d.scope_id='$pid')
246 OR (d.scope='task_type' AND d.scope_id='$tt')
247 )~;
248
249 my @rows = $db->db_readwrite_multiple($dbh, qq~
250 SELECT d.id AS field_id,
251 d.field_key,
252 d.label,
253 d.description,
254 d.field_type,
255 d.options_json,
256 d.is_required,
257 d.placeholder,
258 d.help_text,
259 d.default_value,
260 d.sort_order,
261 d.show_on_queue,
262 d.show_on_card,
263 d.scope,
264 d.scope_id,
265 v.value_text,
266 v.value_json,
267 v.value_num,
268 v.value_date
269 FROM `${DB}`.custom_field_definitions d
270 LEFT JOIN `${DB}`.task_custom_field_values v
271 ON v.field_id = d.id AND v.task_id='$task_id'
272 WHERE $co_filter d.is_active=1
273 $scope_clause
274 ORDER BY d.sort_order ASC, d.label ASC
275 ~, $ENV{SCRIPT_NAME}, __LINE__);
276 return \@rows;
277}
278
279# Bulk fetch values across many tasks (used by tickets.cgi queue when
280# rendering custom-field columns). Returns hashref: { task_id => { field_id => value_struct } }
281sub bulk_values_for_tasks {
282 my ($self, $db, $dbh, $DB, $task_ids) = @_;
283 return {} unless $self->schema_ready($db, $dbh, $DB);
284 return {} unless $task_ids && ref($task_ids) eq 'ARRAY' && @$task_ids;
285 my @ids = grep { /^\d+$/ } @$task_ids;
286 return {} unless @ids;
287 my $in = join(',', map { "'$_'" } @ids);
288
289 my @rows = $db->db_readwrite_multiple($dbh, qq~
290 SELECT task_id, field_id, value_text, value_json, value_num, value_date
291 FROM `${DB}`.task_custom_field_values
292 WHERE task_id IN ($in)
293 ~, $ENV{SCRIPT_NAME}, __LINE__);
294
295 my %out;
296 foreach my $r (@rows) {
297 $out{ $r->{task_id} }->{ $r->{field_id} } = {
298 value_text => $r->{value_text},
299 value_json => $r->{value_json},
300 value_num => $r->{value_num},
301 value_date => $r->{value_date},
302 };
303 }
304 return \%out;
305}
306
307# Upsert a value. The right column is picked based on the field's type.
308sub set_value {
309 my ($self, $db, $dbh, $DB, %a) = @_;
310 return 0 unless $self->schema_ready($db, $dbh, $DB);
311 my $task_id = $a{task_id}; $task_id =~ s/[^0-9]//g;
312 my $field_id = $a{field_id}; $field_id =~ s/[^0-9]//g;
313 return 0 unless $task_id && $field_id;
314
315 my $def = $self->get_definition($db, $dbh, $DB, $field_id);
316 return 0 unless $def;
317
318 my $type = $def->{field_type};
319 my ($text_v, $json_v, $num_v, $date_v) = (undef, undef, undef, undef);
320
321 my $val = $a{value};
322 $val = '' unless defined $val;
323
324 if ($type eq 'text' || $type eq 'textarea' || $type eq 'url'
325 || $type eq 'email' || $type eq 'select' || $type eq 'user') {
326 $text_v = substr($val, 0, 65000);
327 }
328 elsif ($type eq 'multi_select') {
329 # value passed as comma-separated or JSON; store as JSON.
330 my @items;
331 if (ref($val) eq 'ARRAY') { @items = @$val; }
332 elsif ($val =~ /^\[/) { @items = split(/","/, $val); s/^\[?"|"\]?$//g for @items; }
333 else { @items = split(/\s*,\s*/, $val); }
334 @items = map { my $x = $_; $x =~ s/^\s+|\s+$//g; $x } @items;
335 @items = grep { length } @items;
336 my $j = '[' . join(',', map { '"' . do { my $e = $_; $e =~ s/\\/\\\\/g; $e =~ s/"/\\"/g; $e } . '"' } @items) . ']';
337 $json_v = $j;
338 }
339 elsif ($type eq 'checkbox') {
340 $text_v = ($val && $val ne '0' && $val ne 'false' && $val ne 'off') ? '1' : '0';
341 }
342 elsif ($type eq 'number' || $type eq 'currency') {
343 my $n = $val; $n =~ s/[^0-9.\-]//g;
344 $num_v = (length $n) ? $n : undef;
345 }
346 elsif ($type eq 'date') {
347 my $d = $val; $d =~ s/[^0-9\-]//g;
348 $date_v = (length $d) ? "$d 00:00:00" : undef;
349 }
350 elsif ($type eq 'datetime') {
351 my $d = $val; $d =~ s/[^0-9\-: ]//g;
352 $date_v = (length $d) ? $d : undef;
353 }
354
355 my $q_text = defined $text_v ? _q($text_v) : 'NULL';
356 my $q_json = defined $json_v ? _q($json_v) : 'NULL';
357 my $q_num = defined $num_v ? "'$num_v'" : 'NULL';
358 my $q_date = defined $date_v ? _q($date_v) : 'NULL';
359
360 my $actor = $a{actor_user_id}; $actor =~ s/[^0-9]//g if defined $actor;
361 my $actor_sql = ($actor) ? "'$actor'" : 'NULL';
362
363 $db->db_readwrite($dbh, qq~
364 INSERT INTO `${DB}`.task_custom_field_values
365 SET task_id='$task_id',
366 field_id='$field_id',
367 value_text=$q_text,
368 value_json=$q_json,
369 value_num=$q_num,
370 value_date=$q_date,
371 updated_by_user_id=$actor_sql
372 ON DUPLICATE KEY UPDATE
373 value_text=$q_text,
374 value_json=$q_json,
375 value_num=$q_num,
376 value_date=$q_date,
377 updated_by_user_id=$actor_sql
378 ~, $ENV{SCRIPT_NAME}, __LINE__);
379 return 1;
380}
381
382sub delete_value {
383 my ($self, $db, $dbh, $DB, $task_id, $field_id) = @_;
384 return 0 unless $self->schema_ready($db, $dbh, $DB);
385 $task_id =~ s/[^0-9]//g;
386 $field_id =~ s/[^0-9]//g;
387 return 0 unless $task_id && $field_id;
388 $db->db_readwrite($dbh, qq~
389 DELETE FROM `${DB}`.task_custom_field_values
390 WHERE task_id='$task_id' AND field_id='$field_id'
391 ~, $ENV{SCRIPT_NAME}, __LINE__);
392 return 1;
393}
394
395# ---- Render helpers (used by templates) ----------------------------
396# Given a definition row + value row, return the "display" string.
397sub value_display {
398 my ($self, $def, $val) = @_;
399 return '' unless $def && $val;
400 my $type = $def->{field_type};
401 if ($type eq 'checkbox') { return ($val->{value_text} && $val->{value_text} eq '1') ? 'Yes' : 'No'; }
402 if ($type eq 'multi_select') {
403 my $j = $val->{value_json} || '';
404 $j =~ s/^\[|\]$//g;
405 $j =~ s/"//g;
406 return $j;
407 }
408 if ($type eq 'number' || $type eq 'currency') {
409 my $n = $val->{value_num};
410 return '' unless defined $n;
411 return $type eq 'currency' ? sprintf('$%.2f', $n) : $n;
412 }
413 if ($type eq 'date') { my $d = $val->{value_date} || ''; $d =~ s/ 00:00:00$//; return $d; }
414 if ($type eq 'datetime') { return $val->{value_date} || ''; }
415 return defined $val->{value_text} ? $val->{value_text} : '';
416}
417
418# Decode options_json (a JSON array of strings) into a Perl list.
419sub options_list {
420 my ($self, $def) = @_;
421 return () unless $def && $def->{options_json};
422 my $s = $def->{options_json};
423 $s =~ s/^\[|\]$//g;
424 my @items;
425 while ($s =~ /"((?:[^"\\]|\\.)*)"/g) {
426 my $x = $1;
427 $x =~ s/\\"/"/g;
428 $x =~ s/\\\\/\\/g;
429 push @items, $x;
430 }
431 return @items;
432}
433
434# ---- Internal -------------------------------------------------------
435sub _q {
436 my ($s) = @_;
437 return 'NULL' unless defined $s && length $s;
438 $s =~ s/'/''/g;
439 return "'$s'";
440}
441
4421;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help