added on local at 2026-07-01 12:34:34
| 1 | #!/usr/bin/perl | |
| 2 | ||
| 3 | ################################################################################################################################## | |
| 4 | # ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____ | |
| 5 | # / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \ | |
| 6 | # / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / / | |
| 7 | # / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ / | |
| 8 | # \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/ | |
| 9 | # | |
| 10 | # - Written by: Shawn Pickering | |
| 11 | # - shawn@shawnpickering.com | |
| 12 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | my @EP_CATS = ( | |
| 21 | { key => 'lifecycle', label => 'Lifecycle emails', description => 'Trial reminders, plan changes, downgrade notices, winback. We recommend keeping these on.', default_on => 1 }, | |
| 22 | { key => 'product', label => 'Product updates', description => 'New features, release notes, what shipped this month. Once or twice a month.', default_on => 0 }, | |
| 23 | { key => 'marketing', label => 'Tips & marketing', description => 'Onboarding tips, customer stories, occasional promotions.', default_on => 0 }, | |
| 24 | ); | |
| 25 | ||
| 26 | ||
| 27 | ||
| 28 | ############################################################ | |
| 29 | # Modules | |
| 30 | ############################################################ | |
| 31 | #Load the CGI module | |
| 32 | use CGI; | |
| 33 | my $query = new CGI; | |
| 34 | my $form = $query->Vars; | |
| 35 | ||
| 36 | #Load the login module | |
| 37 | use MODS::Login; | |
| 38 | my $login = new MODS::Login; | |
| 39 | ||
| 40 | #Load Urls | |
| 41 | use MODS::PTMatrix::Urls; | |
| 42 | my $getlist = new MODS::PTMatrix::Urls; | |
| 43 | my $url = $getlist->urls(); | |
| 44 | ||
| 45 | #Load the template module | |
| 46 | use MODS::Template; | |
| 47 | my $tfile = new MODS::Template; | |
| 48 | ||
| 49 | #Page Wrapper Module | |
| 50 | use MODS::PTMatrix::Wrapper; | |
| 51 | my $load = new MODS::PTMatrix::Wrapper; | |
| 52 | ||
| 53 | #Load the database connect module | |
| 54 | use MODS::DBConnect; | |
| 55 | my $db = new MODS::DBConnect; | |
| 56 | ||
| 57 | #Load the configuration file | |
| 58 | use MODS::PTMatrix::Config; | |
| 59 | my $config = new MODS::PTMatrix::Config; | |
| 60 | my $database_name = $config->settings('database_name'); | |
| 61 | ||
| 62 | #Shared PM helpers | |
| 63 | use MODS::PTMatrix::PM; | |
| 64 | ||
| 65 | ||
| 66 | ||
| 67 | ############################################################# | |
| 68 | ## Get form data | |
| 69 | ############################################################# | |
| 70 | #Escapes bad characters | |
| 71 | foreach my $line(keys %$form){ | |
| 72 | $form->{$line} =~ s/[^!-~\s]//g; #Remove non ascii chars | |
| 73 | $form->{$line} = quotemeta("$form->{$line}"); | |
| 74 | } | |
| 75 | ||
| 76 | ||
| 77 | ||
| 78 | ############################################################ | |
| 79 | # Program Controls | |
| 80 | ############################################################ | |
| 81 | $|=1; | |
| 82 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'preferences'; | |
| 83 | my $userinfo = $login->login_verify(); | |
| 84 | ||
| 85 | if($entry eq 'save_pref'){ | |
| 86 | print qq~Content-Type: application/json\n\n~; | |
| 87 | if(!$userinfo){print '{"ok":false,"err":"not_auth"}'; exit;} | |
| 88 | &save_ui_pref; | |
| 89 | exit; | |
| 90 | } | |
| 91 | ||
| 92 | print qq~Content-type:text/html; Cache-Control:no-cache;\n\n~; | |
| 93 | if(!$userinfo){print qq~<script>window.location="$url->{login}";</script>~; exit;} | |
| 94 | ||
| 95 | if($entry eq 'email_preferences'){ | |
| 96 | if(($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{act} || '') eq 'save'){&ep_save_prefs;} | |
| 97 | else{&ep_prefs_page;} | |
| 98 | }else{ | |
| 99 | if(($ENV{REQUEST_METHOD}||'') eq 'POST'){&save_prefs;} | |
| 100 | else{&prefs_page;} | |
| 101 | } | |
| 102 | ||
| 103 | ||
| 104 | ############################################################ | |
| 105 | # Subroutines | |
| 106 | ############################################################ | |
| 107 | sub save_prefs{ | |
| 108 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 109 | ||
| 110 | #Validate everything against the allowlists - drop bad values to safe defaults | |
| 111 | my $theme = ($form->{theme} =~ /^(dark|light|auto)$/) ? $form->{theme} : 'dark'; | |
| 112 | my $view = ($form->{default_view} =~ /^(kanban|gantt|list|calendar)$/)? $form->{default_view} : 'kanban'; | |
| 113 | my $n_assigned = $form->{email_notif_assigned} ? 1 : 0; | |
| 114 | my $n_mentioned = $form->{email_notif_mentioned} ? 1 : 0; | |
| 115 | my $n_status = $form->{email_notif_status} ? 1 : 0; | |
| 116 | my $n_due_soon = $form->{email_notif_due_soon} ? 1 : 0; | |
| 117 | my $n_weekly = $form->{email_notif_weekly} ? 1 : 0; | |
| 118 | my $show_tips = $form->{show_tooltips} ? 1 : 0; | |
| 119 | my $grad_mode = ($form->{card_gradient_mode} =~ /^(off|priority|assignee|custom)$/) ? $form->{card_gradient_mode} : 'priority'; | |
| 120 | my $grad_from = MODS::PTMatrix::PM::safe_color($form->{card_gradient_from} || '#5aa9ff'); | |
| 121 | my $grad_to = MODS::PTMatrix::PM::safe_color($form->{card_gradient_to} || '#1a2230'); | |
| 122 | my $density = ($form->{board_density} =~ /^(compact|comfortable|spacious)$/) ? $form->{board_density} : 'comfortable'; | |
| 123 | ||
| 124 | my $dbh = $db->db_connect(); | |
| 125 | my $sql = qq~INSERT INTO `${database_name}`.user_settings (user_id, theme, default_view, show_tooltips, email_notif_assigned, email_notif_mentioned, email_notif_status, email_notif_due_soon, email_notif_weekly, card_gradient_mode, card_gradient_from, card_gradient_to, board_density) VALUES ('$uid', '$theme', '$view', '$show_tips', '$n_assigned', '$n_mentioned', '$n_status', '$n_due_soon', '$n_weekly', '$grad_mode', '$grad_from', '$grad_to', '$density') ON DUPLICATE KEY UPDATE theme=VALUES(theme), default_view=VALUES(default_view), show_tooltips=VALUES(show_tooltips), email_notif_assigned=VALUES(email_notif_assigned), email_notif_mentioned=VALUES(email_notif_mentioned), email_notif_status=VALUES(email_notif_status), email_notif_due_soon=VALUES(email_notif_due_soon), email_notif_weekly=VALUES(email_notif_weekly), card_gradient_mode=VALUES(card_gradient_mode), card_gradient_from=VALUES(card_gradient_from), card_gradient_to=VALUES(card_gradient_to), board_density=VALUES(board_density)~; | |
| 126 | $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 127 | $db->db_disconnect($dbh); | |
| 128 | ||
| 129 | &prefs_page('Preferences saved.'); | |
| 130 | } | |
| 131 | ||
| 132 | sub prefs_page{ | |
| 133 | my($flash) = @_; | |
| 134 | $flash ||= ''; | |
| 135 | ||
| 136 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 137 | my $dbh = $db->db_connect(); | |
| 138 | ||
| 139 | #------------------------------------------------------------------------------------------------------ | |
| 140 | #Read the user's current settings row | |
| 141 | my $sql = qq~SELECT * FROM `${database_name}`.user_settings WHERE user_id='$uid' LIMIT 1~; | |
| 142 | my $s = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 143 | $s ||= {}; | |
| 144 | #------------------------------------------------------------------------------------------------------ | |
| 145 | ||
| 146 | $db->db_disconnect($dbh); | |
| 147 | ||
| 148 | my $mode = $s->{card_gradient_mode} || 'priority'; | |
| 149 | ||
| 150 | #Create all template variables | |
| 151 | my $tvars; | |
| 152 | $tvars->{flash} = $flash; | |
| 153 | $tvars->{has_flash} = length($flash) ? 1 : 0; | |
| 154 | $tvars->{theme_dark} = ($s->{theme} || 'dark') eq 'dark' ? 1 : 0; | |
| 155 | $tvars->{theme_light} = ($s->{theme} || '') eq 'light' ? 1 : 0; | |
| 156 | $tvars->{theme_auto} = ($s->{theme} || '') eq 'auto' ? 1 : 0; | |
| 157 | $tvars->{view_kanban} = ($s->{default_view} || 'kanban') eq 'kanban' ? 1 : 0; | |
| 158 | $tvars->{view_gantt} = ($s->{default_view} || '') eq 'gantt' ? 1 : 0; | |
| 159 | $tvars->{view_list} = ($s->{default_view} || '') eq 'list' ? 1 : 0; | |
| 160 | $tvars->{view_cal} = ($s->{default_view} || '') eq 'calendar' ? 1 : 0; | |
| 161 | $tvars->{n_assigned} = defined $s->{email_notif_assigned} ? ($s->{email_notif_assigned}+0) : 1; | |
| 162 | $tvars->{n_mentioned} = defined $s->{email_notif_mentioned} ? ($s->{email_notif_mentioned}+0) : 1; | |
| 163 | $tvars->{n_status} = defined $s->{email_notif_status} ? ($s->{email_notif_status}+0) : 1; | |
| 164 | $tvars->{n_due_soon} = defined $s->{email_notif_due_soon} ? ($s->{email_notif_due_soon}+0) : 1; | |
| 165 | $tvars->{n_weekly} = defined $s->{email_notif_weekly} ? ($s->{email_notif_weekly}+0) : 0; | |
| 166 | $tvars->{show_tips} = defined $s->{show_tooltips} ? ($s->{show_tooltips}+0) : 1; | |
| 167 | $tvars->{grad_off} = $mode eq 'off' ? 1 : 0; | |
| 168 | $tvars->{grad_priority} = $mode eq 'priority' ? 1 : 0; | |
| 169 | $tvars->{grad_assignee} = $mode eq 'assignee' ? 1 : 0; | |
| 170 | $tvars->{grad_custom} = $mode eq 'custom' ? 1 : 0; | |
| 171 | $tvars->{grad_from} = $s->{card_gradient_from} || '#5aa9ff'; | |
| 172 | $tvars->{grad_to} = $s->{card_gradient_to} || '#1a2230'; | |
| 173 | $tvars->{den_compact} = ($s->{board_density}||'comfortable') eq 'compact' ? 1 : 0; | |
| 174 | $tvars->{den_comfortable} = ($s->{board_density}||'comfortable') eq 'comfortable' ? 1 : 0; | |
| 175 | $tvars->{den_spacious} = ($s->{board_density}||'comfortable') eq 'spacious' ? 1 : 0; | |
| 176 | ||
| 177 | my $body = join('', $tfile->template('tf_preferences.html', $tvars, $userinfo)); | |
| 178 | ||
| 179 | $load->render({ | |
| 180 | userinfo => $userinfo, | |
| 181 | page_key => 'preferences', | |
| 182 | title => 'Preferences', | |
| 183 | body => $body, | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | #============================================================ | |
| 188 | # email_preferences entry: per-category email opt-outs | |
| 189 | #============================================================ | |
| 190 | sub ep_save_prefs{ | |
| 191 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 192 | my $dbh = $db->db_connect(); | |
| 193 | ||
| 194 | foreach my $c(@EP_CATS){ | |
| 195 | my $sub = $form->{"pref_$c->{key}"} ? 1 : 0; | |
| 196 | my $sql = qq~INSERT INTO `${database_name}`.user_email_preferences (user_id, category, is_subscribed) VALUES ('$uid', '$c->{key}', '$sub') ON DUPLICATE KEY UPDATE is_subscribed='$sub'~; | |
| 197 | $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 198 | } | |
| 199 | ||
| 200 | $db->db_disconnect($dbh); | |
| 201 | &ep_prefs_page('Preferences saved.'); | |
| 202 | } | |
| 203 | ||
| 204 | sub ep_prefs_page{ | |
| 205 | my($flash) = @_; | |
| 206 | $flash ||= ''; | |
| 207 | ||
| 208 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 209 | my $dbh = $db->db_connect(); | |
| 210 | ||
| 211 | my $sql_prefs = qq~SELECT category, is_subscribed FROM `${database_name}`.user_email_preferences WHERE user_id='$uid'~; | |
| 212 | my @rows = $db->db_readwrite_multiple($dbh, $sql_prefs, $ENV{SCRIPT_NAME}, __LINE__); | |
| 213 | my %prefs; | |
| 214 | foreach my $r(@rows){$prefs{$r->{category}} = $r->{is_subscribed} ? 1 : 0;} | |
| 215 | ||
| 216 | my $sql_recent = qq~SELECT template_slug, subject, status, created_at FROM `${database_name}`.email_sends_log WHERE user_id='$uid' ORDER BY id DESC LIMIT 10~; | |
| 217 | my @recent = $db->db_readwrite_multiple($dbh, $sql_recent, $ENV{SCRIPT_NAME}, __LINE__); | |
| 218 | ||
| 219 | $db->db_disconnect($dbh); | |
| 220 | ||
| 221 | my $flash_html = $flash ? qq~<div style="padding:10px 14px;background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.3);border-radius:8px;color:#86efac;font-size:13px;margin-bottom:18px">$flash</div>~ : ''; | |
| 222 | ||
| 223 | my $rows_html = ''; | |
| 224 | foreach my $c(@EP_CATS){ | |
| 225 | my $sub = defined($prefs{$c->{key}}) ? $prefs{$c->{key}} : $c->{default_on}; | |
| 226 | my $checked = $sub ? 'checked' : ''; | |
| 227 | $rows_html .= qq~ | |
| 228 | <label style="display:flex;align-items:flex-start;gap:14px;padding:16px;border:1px solid rgba(255,255,255,.08);border-radius:10px;margin-bottom:10px;cursor:pointer;background:rgba(20,20,30,.4)"> | |
| 229 | <input type="checkbox" name="pref_$c->{key}" value="1" $checked style="margin-top:3px;width:18px;height:18px"> | |
| 230 | <div> | |
| 231 | <div style="font-weight:600;color:#e2e8f0;font-size:14px">$c->{label}</div> | |
| 232 | <div style="color:#94a3b8;font-size:13px;line-height:1.5;margin-top:4px">$c->{description}</div> | |
| 233 | </div> | |
| 234 | </label>~; | |
| 235 | } | |
| 236 | ||
| 237 | my $recent_html = ''; | |
| 238 | foreach my $r(@recent){ | |
| 239 | my $color = $r->{status} eq 'sent' ? '#86efac' : ($r->{status} =~ /^suppressed/ ? '#fbbf24' : '#fca5a5'); | |
| 240 | my $sj = MODS::PTMatrix::PM::h($r->{subject}); | |
| 241 | $recent_html .= qq~ | |
| 242 | <div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);font-size:12px"> | |
| 243 | <div><span style="color:#cbd5e1">$sj</span><div style="color:#64748b;font-size:11px;font-family:ui-monospace,Consolas,Menlo,monospace">$r->{template_slug}</div></div> | |
| 244 | <div><span style="color:$color;font-size:11px;text-transform:uppercase;letter-spacing:.05em">$r->{status}</span><div style="color:#94a3b8;font-size:11px;text-align:right">$r->{created_at}</div></div> | |
| 245 | </div>~; | |
| 246 | } | |
| 247 | ||
| 248 | my $body = qq~ | |
| 249 | <div class="page-pad" style="max-width:720px"> | |
| 250 | <h1 style="margin:0 0 6px;font-size:28px;color:#fff">Email preferences</h1> | |
| 251 | <p style="color:#94a3b8;margin:0 0 24px;font-size:14px">Choose which emails you'd like to receive from PTMatrix. Transactional emails (password resets, invite acceptance, billing receipts) are always sent regardless.</p> | |
| 252 | $flash_html | |
| 253 | ||
| 254 | <form method="POST" action="$url->{email_preferences}"> | |
| 255 | <input type="hidden" name="act" value="save"> | |
| 256 | $rows_html | |
| 257 | <div style="margin-top:18px"><button type="submit" class="btn btn-primary btn-lg">Save preferences</button></div> | |
| 258 | </form> | |
| 259 | ||
| 260 | <h2 style="font-size:16px;color:#fff;margin:32px 0 12px">Recent emails sent to you</h2> | |
| 261 | <div style="border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:8px 16px;background:rgba(20,20,30,.4)">$recent_html</div> | |
| 262 | </div>~; | |
| 263 | ||
| 264 | $load->render({ | |
| 265 | userinfo => $userinfo, | |
| 266 | page_key => 'email_preferences', | |
| 267 | title => 'Email preferences', | |
| 268 | body => $body, | |
| 269 | }); | |
| 270 | } | |
| 271 | ||
| 272 | #============================================================ | |
| 273 | # save_pref entry: AJAX UI-pref JSON endpoint | |
| 274 | #============================================================ | |
| 275 | sub save_ui_pref{ | |
| 276 | my $uid = ($userinfo->{user_id} || 0) + 0; | |
| 277 | my $dbh = $db->db_connect(); | |
| 278 | if(!$dbh){print '{"ok":false,"err":"db"}'; return;} | |
| 279 | ||
| 280 | my $has_mob_col = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '${database_name}' AND TABLE_NAME = 'user_settings' AND COLUMN_NAME = 'sidebar_mobile_open'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 281 | my $mobile_col_ready = ($has_mob_col && $has_mob_col->{n}) ? 1 : 0; | |
| 282 | ||
| 283 | my @insert_cols = ('user_id'); | |
| 284 | my @insert_vals = ("'$uid'"); | |
| 285 | my @update_clause; | |
| 286 | ||
| 287 | if(exists $form->{sidebar_collapsed}){ | |
| 288 | my $v = ($form->{sidebar_collapsed} || 0) ? 1 : 0; | |
| 289 | push @insert_cols, 'sidebar_collapsed'; | |
| 290 | push @insert_vals, "'$v'"; | |
| 291 | push @update_clause, "sidebar_collapsed='$v'"; | |
| 292 | } | |
| 293 | if($mobile_col_ready && exists $form->{sidebar_mobile_open}){ | |
| 294 | my $v = ($form->{sidebar_mobile_open} || 0) ? 1 : 0; | |
| 295 | push @insert_cols, 'sidebar_mobile_open'; | |
| 296 | push @insert_vals, "'$v'"; | |
| 297 | push @update_clause, "sidebar_mobile_open='$v'"; | |
| 298 | } | |
| 299 | ||
| 300 | if(@update_clause){ | |
| 301 | my $cols_sql = join(',', @insert_cols); | |
| 302 | my $vals_sql = join(',', @insert_vals); | |
| 303 | my $update_sql = join(',', @update_clause); | |
| 304 | my $sql = qq~INSERT INTO `${database_name}`.user_settings ($cols_sql) VALUES ($vals_sql) ON DUPLICATE KEY UPDATE $update_sql~; | |
| 305 | $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 306 | } | |
| 307 | $db->db_disconnect($dbh); | |
| 308 | print '{"ok":true}'; | |
| 309 | } |