Compare commits

...

196 commits
go ... master

Author SHA1 Message Date
Karl Cordes a251da2d95 Maybe this will actually fix the emojibake problem 2025-12-23 17:05:32 +11:00
Karl Cordes b3b0c040f3 Hopefully fix emojibake errors 2025-12-23 16:56:21 +11:00
Karl Cordes 7f3cb5cbaa Add scripts to fix emojibake corruption 2025-12-23 15:54:26 +11:00
Karl Cordes bc958b6acf Scripts to fix encoding problems #137 2025-12-10 07:27:36 +11:00
Finley Ghosh 5db8451f2c Adding force recreate when no-cache 2025-12-08 21:47:54 +11:00
Finley Ghosh 34747336dd Changing attachments to use php path 2025-12-08 21:41:22 +11:00
Finley Ghosh a99b818f6b Adding new file mounts 2025-12-08 21:31:11 +11:00
Finley Ghosh a5ccdd8472 Reworking attachment logic in Go, bumping Nginx max file upload size, fixing local dev env, changes to .gitignore 2025-12-08 20:39:45 +11:00
Karl Cordes 52d50e8b57 Remove print_r from PO revision 2025-12-08 17:16:55 +11:00
Finley Ghosh bb5d48a0a0 Quick fix 2025-12-08 00:04:40 +11:00
Finley Ghosh 3950cef4c9 Ensuring automatic quote reminder emails have pdfs 2025-12-07 22:15:09 +11:00
Finley Ghosh 57e7f7fe24 Making re-enable modal the same width 2025-12-07 21:27:48 +11:00
Finley Ghosh 2459df569e Fixing bug where quote revisions would not set the user, fixing quotes not showing up if they don't have a valid user 2025-12-07 21:18:35 +11:00
Finley Ghosh e11013bd9f Fixing some quotes not showing up 2025-12-07 19:04:20 +11:00
Finley Ghosh a579380905 Adding prune to deploy scripts to prevent docker resource overflowing 2025-12-07 18:10:33 +11:00
Finley Ghosh 016f77a2c9 Streamlining quote reminder logging 2025-12-07 18:08:35 +11:00
Finley Ghosh e94cabfc3e Fixing disable automatic reminders, adding better visuals 2025-12-07 18:06:23 +11:00
Finley Ghosh 793b8b4a7d fixing disable future error 2025-12-07 17:51:08 +11:00
Finley Ghosh b62877d1bd Adding interface reference, fixing manual quote reminders dropdown width / anchor 2025-12-07 17:35:21 +11:00
Finley Ghosh d8526f12b6 Using document version to get latest quotes 2025-12-07 17:18:04 +11:00
Finley Ghosh b62d7fdb17 Better modals and visuals, adding method to re-enabled disabled reminders 2025-12-07 16:53:59 +11:00
Finley Ghosh c4d60bc1c9 Fixing automated goose script 2025-12-07 16:21:46 +11:00
Finley Ghosh 6668e1af4f Adding check for existing ssh tunnel 2025-12-07 16:15:36 +11:00
Finley Ghosh 942522458f Moving fully to goose migration 2025-12-07 16:13:40 +11:00
Finley Ghosh 8de7fc675b Checking a different way 2025-12-07 15:56:50 +11:00
Finley Ghosh e6d444c4af Updating migration script 2025-12-07 15:55:14 +11:00
Finley Ghosh f6bbfb3c83 Adding new migrations script supporting goose 2025-12-07 15:53:41 +11:00
Finley Ghosh b0a09c159d Adding endpoint to disable reminders, fixing pdf attachment 2025-12-07 12:50:28 +11:00
Finley Ghosh bbdd035d04 Adding ability to disable automatic reminders 2025-12-07 12:44:30 +11:00
Finley Ghosh 57047e3b32 Centralising auth, making pdf retrieval via authenticated request 2025-12-07 12:27:41 +11:00
Finley Ghosh 46cf098dfa Updating pdf attachment logic 2025-12-05 22:17:38 +11:00
Finley Ghosh d898149810 Adding manual email templates, adding support for pdf attachments in emails, attaching quote to reminder if possible 2025-12-04 00:35:03 +11:00
Finley Ghosh 8dbf221908 adding back .env.example 2025-12-04 00:08:27 +11:00
Finley Ghosh 97d7420f17 Improving modal text 2025-12-04 00:03:49 +11:00
Finley Ghosh 1d8753764f Adding ability to manually send reminders 2025-12-03 23:59:57 +11:00
Finley Ghosh c496e7232f Refurbishing db refresher script for local use 2025-12-03 22:37:44 +11:00
Finley Ghosh 23853455f4 Getting local development running again 2025-12-03 22:21:12 +11:00
Finley Ghosh 9eb7747c45 Changing repo in deploy scripts 2025-12-02 22:09:49 +11:00
Finley Ghosh ee70c11431 Removing mcarpis from default bcc 2025-12-02 22:08:33 +11:00
Karl Cordes ba770cb87d Fix timezone 2025-11-26 07:23:19 +11:00
Karl Cordes e4c8fa8a57 Maybe fix PDF generation failing 2025-11-25 14:06:24 +11:00
Karl Cordes 1b5a23b3c0 Fix vault attachment directory 2025-11-24 21:59:54 +11:00
Karl Cordes ce5d44ae6b Fix Jonathan password 2025-11-24 13:35:16 +11:00
Karl Cordes 50d2541600 Set mariadb sql_mode for CakePHP compatibility 2025-11-24 10:28:07 +11:00
Karl Cordes ee182f3c6e Hopefully fix attachments directory misconfig 2025-11-23 16:09:25 +11:00
Karl Cordes e7babb7523 Merge branch 'prod' 2025-11-23 13:52:48 +11:00
Karl Cordes 953a9f473c Fix vault attachments filepath 2025-11-23 13:51:11 +11:00
Finley Ghosh 40e9f98fef Adding -p flag to mkdir 2025-11-23 13:08:20 +11:00
Finley Ghosh f6b3d90297 Merge branch 'stg' 2025-11-23 13:03:23 +11:00
Finley Ghosh 1f116c09ba Adding README 2025-11-23 13:03:06 +11:00
finley 8c118098bf Merge pull request 'prod' (#123) from prod into master
Reviewed-on: cmc/cmc-sales#123
Reviewed-by: kzrl <karl@cordes.com.au>
2025-11-22 17:52:39 -08:00
finley 352ee4cfcd Merge pull request 'migration/reorg' (#124) from migration/reorg into stg
Reviewed-on: cmc/cmc-sales#124
2025-11-22 17:36:31 -08:00
Finley Ghosh 88ffe7bcd9 Moving php and go into separate dirs, moving scripts into a central dir, updating files as necessary 2025-11-23 12:30:24 +11:00
Finley Ghosh 702186000d Merge branch 'prod' of code.springupsoftware.com:cmc/cmc-sales into prod 2025-11-23 12:08:18 +11:00
Finley Ghosh 678e9be2b2 Enabling rclone for db backup, changing to master branch for prod deploy, archiving migration scripts, adding backups/ to gitignore 2025-11-23 12:08:10 +11:00
Karl Cordes 98d5a415a6 Add go vault to the prod docker 2025-11-23 10:39:57 +11:00
Finley Ghosh 5646851ae3 Removing port from postfix 2025-11-23 09:34:34 +11:00
Finley Ghosh f9ea771d46 Removing creds from docker compose 2025-11-23 09:31:16 +11:00
Finley Ghosh 40560f17a5 Fixing 2025-11-23 09:29:21 +11:00
Finley Ghosh 477f9c39d6 referencing .env file 2025-11-23 09:26:51 +11:00
Finley Ghosh 4af8afaece Moving secrets to a .env file for dockerfile 2025-11-23 09:24:47 +11:00
Finley Ghosh 5e36cc304e escaping password 2025-11-23 09:19:02 +11:00
Finley Ghosh d794ded611 Merge branch 'prod' of code.springupsoftware.com:cmc/cmc-sales into prod 2025-11-23 09:14:18 +11:00
Finley Ghosh c4a458c352 Removing run_vault.sh reference 2025-11-23 09:14:08 +11:00
finley 3e9bf95da5 Merge pull request 'Switching to gmail relay' (#122) from migration/go-live into prod
Reviewed-on: cmc/cmc-sales#122
2025-11-22 14:09:49 -08:00
Finley Ghosh 4e769e5591 Cleaning up repo, updating README.md 2025-11-20 23:44:07 +11:00
Finley Ghosh f4cf670684 Switching to gmail relay 2025-11-20 23:26:23 +11:00
Finley Ghosh da4eefdbf6 fix 2025-11-20 23:12:44 +11:00
Finley Ghosh 4ae4ca097a trying again 2025-11-20 23:05:52 +11:00
Finley Ghosh 59c77fcf15 Getting back recipients 2025-11-20 22:55:33 +11:00
Finley Ghosh 96bf160198 Trying to fix email sending 2025-11-20 22:50:02 +11:00
Finley Ghosh f873d51c86 Fixing emails mounts 2025-11-20 21:32:52 +11:00
Finley Ghosh 1837ed2dca fixing dbs 2025-11-20 21:19:24 +11:00
Finley Ghosh 33990fad71 renaming db containers 2025-11-20 21:02:19 +11:00
Finley Ghosh 445b5bf16a Moving to proper databases 2025-11-20 21:02:00 +11:00
Finley Ghosh 277349b528 ugh 2025-11-20 01:19:44 +11:00
Finley Ghosh 1cd6f484cd Adding apache vhost config change 2025-11-20 01:04:54 +11:00
Finley Ghosh a3e0fcf3bb Add go mod tidy 2025-11-20 00:42:29 +11:00
Finley Ghosh d0a20c5529 Changing ports 2025-11-20 00:40:23 +11:00
Finley Ghosh d185f4569b Adding file mounts for stg 2025-11-20 00:33:18 +11:00
Finley Ghosh a08b51ec81 Removing startup scripts from db 2025-11-20 00:22:33 +11:00
Finley Ghosh 64872445c5 Adding files mounts, adding go mod tidy in build 2025-11-20 00:10:42 +11:00
Finley Ghosh 999a208e51 Merge branch 'prod' of code.springupsoftware.com:cmc/cmc-sales into prod 2025-11-19 23:20:53 +11:00
Finley Ghosh e68c6d1f22 moving to new server 2025-11-19 23:20:42 +11:00
Karl Cordes ff2c48a289 Add Jonathan to userpasswd 2025-11-19 18:26:22 +11:00
Karl Cordes b21ab4a7bf Merge branch 'master' into prod 2025-11-19 07:21:33 +11:00
Karl Cordes 2ff5461784 Fix colleen passwd 2025-11-03 21:41:34 +11:00
Karl Cordes 8e373e677b Fix colleen password 2025-11-03 21:33:39 +11:00
Karl Cordes 37b474992b Add colleen userpasswd 2025-11-03 21:30:04 +11:00
Finley Ghosh f9c562a85d Removing error when already sent email 2025-09-17 21:23:35 +10:00
Finley Ghosh eecb120a93 Adding install for tz info 2025-09-17 21:18:27 +10:00
Finley Ghosh d42089a3e4 Merge branch 'stg' into prod 2025-09-17 21:12:48 +10:00
Finley Ghosh 161350e218 Fixing networks 2025-09-17 21:09:44 +10:00
Finley Ghosh 4bec2d1356 Adding allowed senders 2025-09-15 22:03:25 +10:00
Finley Ghosh 5674557981 Removing ports for stg 2025-09-15 21:50:43 +10:00
Finley Ghosh 2eef930d75 Changing compose file name 2025-09-15 21:47:56 +10:00
Finley Ghosh 5f9a5b74ee Adding stg network 2025-09-15 21:46:43 +10:00
Finley Ghosh b36ead6237 trying again 2025-09-14 20:59:15 +10:00
Finley Ghosh 0251ffd2d7 fixing postfix forwarding to mailpit 2025-09-14 20:55:19 +10:00
Finley Ghosh 108080b9e0 Adding postfix to the network 2025-09-14 17:49:07 +10:00
Finley Ghosh 0c1c7de5c2 Adding in backup script, removing unnecessary scripts 2025-09-14 17:30:27 +10:00
Finley Ghosh 0d52f483bf Fixing ssh in script, removing external from network 2025-09-14 12:05:48 +10:00
Finley Ghosh 5e825162af Adding sync script, adding mailtpit to both networks, minor container renames 2025-09-14 11:54:41 +10:00
Finley Ghosh e5041ef3a1 Explicitly allowing domains for postfix 2025-09-14 11:30:55 +10:00
Finley Ghosh 1531db26d7 Moving postfix 2025-09-13 22:42:31 +10:00
Finley Ghosh d793f7b6ed Moving everything to postfix relay 2025-09-13 22:40:19 +10:00
Finley Ghosh c54d50d3fc Getting username from basic auth, removing excess logging 2025-09-13 21:27:32 +10:00
Finley Ghosh 32f77e0ae1 Renaming / removing files for ease of deploying between all envs 2025-09-13 21:16:51 +10:00
finley 5d180c10c5 Merge pull request 'prod' (#119) from prod into stg
Reviewed-on: cmc/cmc-sales#119
2025-09-13 01:13:39 -07:00
Finley Ghosh eb130720b9 Updating scripts, changing db dockerfile 2025-09-13 17:53:26 +10:00
Finley Ghosh 8866703a9f Pointing docker compose file to correct nginx 2025-09-13 17:17:23 +10:00
Finley Ghosh 62c44c3fda Updating nginx to point to correct containers 2025-09-13 16:52:10 +10:00
Finley Ghosh c059b38e80 Changing ports 2025-08-22 21:09:16 +10:00
Finley Ghosh 9e1c5e95f0 Changing ports 2025-08-22 20:54:50 +10:00
Finley Ghosh a47d1e9e51 Removing mailpit from prod 2025-08-22 20:37:15 +10:00
Finley Ghosh 161f404129 Adding deploy script 2025-08-22 19:42:54 +10:00
Finley Ghosh b76510accc Attempting to build prod env 2025-08-22 19:38:13 +10:00
Karl Cordes cfefe25b9a Change Tailscale auth header var name
Add deploy-production.sh
2025-08-19 17:42:15 +10:00
Karl Cordes e211f58fc6 Debugging tailscale auth 2025-08-18 21:55:54 +10:00
Karl Cordes 923fdee657 Fix php logging to STDERR so Docker logs work 2025-08-18 21:47:48 +10:00
Karl Cordes e469e14ae1 Add tailscale auth 2025-08-18 21:10:33 +10:00
Finley Ghosh 2e8855040d Adding rewrite 2025-08-12 21:35:12 +10:00
Finley Ghosh 42aecef5c2 disabling debug mode 2025-08-12 21:30:43 +10:00
Finley Ghosh 68b2a37731 sigh 2025-08-12 21:21:22 +10:00
Finley Ghosh b4fba46f13 Another go 2025-08-12 21:15:49 +10:00
Finley Ghosh 6e15ee90d3 More things to try 2025-08-12 21:13:46 +10:00
Finley Ghosh 409c9aac5b Adding sys libraries 2025-08-12 21:10:54 +10:00
Finley Ghosh 6b801822fd Adding sys libraries 2025-08-12 21:09:21 +10:00
Finley Ghosh 86de417427 Installing common tools for the container 2025-08-12 21:04:51 +10:00
Finley Ghosh d8d361d06c Adding a2enmod headers 2025-08-12 20:57:16 +10:00
Finley Ghosh f2917c11bc Fixing vhost 2025-08-12 20:49:59 +10:00
Finley Ghosh f5a12d6a2d Trying something else again 2025-08-11 17:44:22 +10:00
Finley Ghosh c810163cc6 Changing smtp from 2025-08-11 17:00:59 +10:00
Finley Ghosh 315b1b78e5 Trying with a different image 2025-08-11 16:57:52 +10:00
Finley Ghosh a5bcbcbdeb Fixing smtp records 2025-08-11 16:52:30 +10:00
Finley Ghosh 65273b3816 Removing a2enmod 2025-08-11 16:45:55 +10:00
Finley Ghosh 727301c195 Adding something else 2025-08-11 16:24:44 +10:00
Finley Ghosh fa02bf24e4 trying to disable proxy_fcgi 2025-08-11 16:14:18 +10:00
Finley Ghosh 284a28d9f0 Updating smtp settings 2025-08-11 15:55:09 +10:00
Finley Ghosh dcb5186d89 Enabling debug mode for php 2025-08-11 15:50:32 +10:00
Finley Ghosh b44cbb68d9 Adding in some testing quote checks and logging"
"
2025-08-11 15:48:12 +10:00
Finley Ghosh eea4853a0f Fixing go .env file being incorrect 2025-08-11 15:15:53 +10:00
Finley Ghosh 9cbe9b5177 Adding db dockerfile 2025-08-10 22:38:12 +10:00
Finley Ghosh 47b9e8e709 force update 2025-08-10 22:37:11 +10:00
Finley Ghosh 885c02543f Moving emails, better sync logic 2025-08-10 22:35:26 +10:00
Finley Ghosh 52ac142907 Moving scripts to their own files, adding a basic migration script, marking most sql files for ignore 2025-08-10 19:21:37 +10:00
Finley Ghosh 36e95d36f7 Adding a new migration script 2025-08-10 18:46:35 +10:00
Finley Ghosh 851a1f8ac6 Removing strange mount 2025-08-10 17:14:08 +10:00
Finley Ghosh d8fe1cbac3 Re-enabling the db restore script 2025-08-10 16:56:43 +10:00
Finley Ghosh a9fb2adc92 trying something else 2025-08-10 16:55:02 +10:00
Finley Ghosh 112a0023b2 Removing virtual host block 2025-08-10 12:35:59 +10:00
Finley Ghosh 61d7b54628 Changing vhost 2025-08-10 12:27:00 +10:00
Finley Ghosh 33260df8db Spinning up a db and pointing there 2025-08-10 12:15:28 +10:00
Finley Ghosh 8634d89912 Idk what its doing but lets see 2025-08-10 11:58:25 +10:00
Finley Ghosh 09f2db82f5 Trying a community maintained image 2025-08-10 11:55:12 +10:00
Finley Ghosh ffc5831f51 Allowing apt to use repos without release files 2025-08-10 11:50:10 +10:00
Finley Ghosh a46ca9d0ab Changing apt sources for EOL ubuntu 2025-08-10 11:47:35 +10:00
Finley Ghosh 33795bb47f Chaning stg dockerfile to use ubuntu 16.04, minor changes to use vps4 for staging 2025-08-10 11:45:08 +10:00
Karl Cordes 97dbeb6a1c Revert "Giant changes to make it work on php7"
This reverts commit 144a7a0fb1.
2025-08-08 13:39:37 +10:00
Karl Cordes 144a7a0fb1 Giant changes to make it work on php7 2025-08-08 13:31:27 +10:00
Karl Cordes 2ea0398f41 Yolo changes to make this work 2025-08-08 11:24:48 +10:00
Karl Cordes 6f538e3e4d Various changes to make staging work 2025-08-08 11:22:06 +10:00
Karl Cordes dc2b67d300 Add docker-compose.caddy and Makefile 2025-08-07 21:53:30 +10:00
Karl Cordes 30f84fefe7 Fix docker-compose.production.yml 2025-08-06 13:50:16 +10:00
Karl Cordes bb34ae5881 Fix staging docker compose 2025-08-06 13:49:21 +10:00
Karl Cordes 687739e9d6 Work on staging and vault 2025-08-05 07:50:12 +10:00
Karl Cordes f6eef99d47 Add go rewrite of vault 2025-07-30 19:54:10 +10:00
finley bc7c99007b Accounting for multiple containers on the one machine 2025-07-29 21:16:01 -07:00
Finley Ghosh 3684a81b7c Updating .env to use proper db creds, renaming nginx 2025-07-29 22:35:45 +10:00
Finley Ghosh 9e20068f8c Trying to improve stg 2025-07-29 22:12:04 +10:00
Finley Ghosh e1a3d20332 Changing mounts 2025-07-23 22:50:12 +10:00
Finley Ghosh 761e48ca62 Trying to mount the file dirs 2025-07-23 22:19:21 +10:00
Finley Ghosh c73af8eef1 Removing file sync from script, changing ports 2025-07-23 22:16:27 +10:00
Finley Ghosh 44d444503f Rearranging so db restore happens 2025-07-23 22:02:23 +10:00
Finley Ghosh e834cd325f Adding progress bars 2025-07-23 21:55:10 +10:00
Finley Ghosh 42f84b7b74 Adding some additional logging 2025-07-23 21:52:57 +10:00
Finley Ghosh ea773fbd7c Adding no perms to rsync 2025-07-23 21:39:01 +10:00
Finley Ghosh b8059fbbbb Adding backup restore to the sync script, omitting dir times with rsync 2025-07-23 21:37:02 +10:00
Finley Ghosh 049256025a Adding sync script to the deploy, changing go dockerfile to multi stage 2025-07-23 21:22:50 +10:00
Finley Ghosh 87caa649ed Using staging nginx conf, changing db name 2025-07-23 20:52:43 +10:00
Finley Ghosh b3ce7a1054 Dockerfile changes 2025-07-23 20:44:16 +10:00
Finley Ghosh 5953bfa8d5 Moving to stg nginx conf, moving to staging dockerfile without air 2025-07-23 20:40:54 +10:00
Finley Ghosh 0d440132ca Minor fixes" 2025-07-23 20:17:47 +10:00
Finley Ghosh fac6d0254a Adding stg deploy script 2025-07-23 20:00:06 +10:00
Finley Ghosh eb10de500c Merge branch 'master' into finley/114-quote-reminders 2025-07-23 18:31:24 +10:00
Finley Ghosh 057231a414 Resolving merge conflicts 2025-07-20 18:05:58 +10:00
finley c2eb01b59a Merge pull request 'Adding default bccs, adding commented out test email config' (#116) from finley/110-extra-ccs into master
Reviewed-on: cmc/cmc-sales#116
2025-07-20 01:04:44 -07:00
finley ff729adf55 Merge branch 'master' into finley/110-extra-ccs 2025-07-20 01:03:00 -07:00
Finley Ghosh 7d8953f311 Adding default bccs, adding commented out test email config 2025-07-20 18:02:09 +10:00
Finley Ghosh 0b70b2c209 Adding default bccs 2025-07-20 17:18:02 +10:00
Finley Ghosh 2a601df758 Updating the gocron job to run at 8am sydney time each day 2025-07-20 16:53:20 +10:00
Finley Ghosh e4453a56fc Quote reminders finalised in go, adding basic tests 2025-07-20 16:50:09 +10:00
Finley Ghosh 6276167663 Adding reminder cron job for email sending, adding ui to view expiring quotes, adding db refresh script 2025-07-17 23:35:30 +10:00
Finley Ghosh 9fee1677e2 Adding basic quotes expiring page 2025-07-14 23:26:26 +10:00
Finley Ghosh e97be7e261 Adding links between the two environments 2025-07-14 21:37:40 +10:00
Finley Ghosh 4cd67eaf6c Adding go basic layout 2025-07-13 22:50:47 +10:00
2798 changed files with 20119 additions and 5837 deletions

10
.gitignore vendored
View file

@ -2,6 +2,8 @@ app/tmp/*
*.tar.gz
*.swp
*.swo
.env.prod
.env.stg
app/vendors/tcpdf/cache/*
app/tests/*
app/emails/*
@ -11,3 +13,11 @@ app/vaultmsgs/*
app/cake_eclipse_helper.php
app/webroot/pdf/*
app/webroot/attachments_files/*
backups/*
# Go binaries
go/server
go/vault
go/go.mod
go/go.sum
go/goose.env

View file

@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
CMC Sales is a B2B sales management system for CMC Technologies. The codebase consists of:
- **Legacy CakePHP 1.2.5 application** (2008-era) - Primary business logic
- **Modern Go API** (in `/go-app/`) - New development using sqlc and Gorilla Mux
- **Legacy CakePHP 1.2.5 application** (in `/php/`) - Primary business logic
- **Modern Go API** (in `/go/`) - New development using sqlc and Gorilla Mux
**Note**: Documentation also references a Django application that is not present in the current codebase.
@ -42,7 +42,7 @@ gunzip < backups/backup_*.sql.gz | mariadb -h 127.0.0.1 -u cmc -p cmc
### Go Application Development
```bash
# Navigate to Go app directory
cd go-app
cd go
# Configure private module access (first time only)
go env -w GOPRIVATE=code.springupsoftware.com
@ -80,7 +80,7 @@ make build
### Go Application (Modern)
- **Framework**: Gorilla Mux (HTTP router)
- **Database**: sqlc for type-safe SQL queries
- **Location**: `/go-app/`
- **Location**: `/go/`
- **Structure**:
- `cmd/server/` - Main application entry point
- `internal/cmc/handlers/` - HTTP request handlers

View file

@ -1,61 +0,0 @@
FROM ghcr.io/kzrl/ubuntu:lucid
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
RUN sed -i 's/archive/old-releases/' /etc/apt/sources.list
RUN apt-get update
RUN apt-get -y upgrade
# Install apache, PHP, and supplimentary programs. curl and lynx-cur are for debugging the container.
RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
# Enable apache mods.
#RUN php5enmod openssl
RUN a2enmod php5
RUN a2enmod rewrite
RUN a2enmod headers
# Update the PHP.ini file, enable <? ?> tags and quieten logging.
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
#RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
ADD conf/php.ini /etc/php5/apache2/php.ini
# Manually set up the apache environment variables
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_LOG_DIR /var/log/apache2
ENV APACHE_LOCK_DIR /var/lock/apache2
ENV APACHE_PID_FILE /var/run/apache2.pid
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Update the default apache site with the config we created.
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime
RUN a2dissite 000-default
RUN a2ensite cmc-sales
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
# Copy site into place.
ADD . /var/www/cmc-sales
RUN chmod +x /var/www/cmc-sales/run_vault.sh
RUN chmod +x /var/www/cmc-sales/run_update_invoices.sh
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

View file

@ -1,50 +0,0 @@
# Build stage
FROM golang:1.23-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /app
# Copy go mod files
COPY go-app/go.mod go-app/go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY go-app/ .
# Install sqlc (compatible with Go 1.23+)
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# Generate sqlc code
RUN sqlc generate
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the binary from builder
COPY --from=builder /app/server .
# Copy templates and static files
COPY go-app/templates ./templates
COPY go-app/static ./static
# Copy .env file if needed
COPY go-app/.env.example .env
# Expose port
EXPOSE 8080
# Run the application
CMD ["./server"]

27
Dockerfile.local.go Normal file
View file

@ -0,0 +1,27 @@
FROM golang:1.24-alpine
WORKDIR /app
# Copy go.mod and go.sum first
COPY go/go.mod go/go.sum ./
# Download dependencies
RUN go mod download
# Install Air for hot reload (pinned to v1.52.3 for Go 1.24 compatibility)
RUN go install github.com/air-verse/air@v1.52.3
# Install sqlc for SQL code generation
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# Copy source code
COPY go/ .
# Generate sqlc code
RUN sqlc generate
# Copy Air config
COPY go/.air.toml .air.toml
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]

47
Dockerfile.local.php Normal file
View file

@ -0,0 +1,47 @@
# Use the official PHP 5.6 Apache image for classic mod_php
FROM php:5.6-apache
# Install required system libraries and PHP extensions for CakePHP
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
apt-get update && \
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Copy vhost config to Apache's sites-available
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime \
&& a2ensite cmc-sales \
&& a2dissite 000-default \
&& a2enmod rewrite \
&& a2enmod headers
# Copy site into place.
ADD php/ /var/www/cmc-sales
ADD php/app/config/database_local.php /var/www/cmc-sales/app/config/database.php
RUN mkdir -p /var/www/cmc-sales/app/tmp
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
# Ensure CakePHP tmp directory is writable by web server
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

4
Dockerfile.prod.db Normal file
View file

@ -0,0 +1,4 @@
FROM mariadb:latest
# Copy custom MariaDB configuration to disable strict mode
COPY conf/mariadb-no-strict.cnf /etc/mysql/conf.d/

23
Dockerfile.prod.go Normal file
View file

@ -0,0 +1,23 @@
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go/go.mod go/go.sum ./
RUN go mod download
COPY go/ .
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
RUN sqlc generate
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o vault cmd/vault/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/server .
COPY --from=builder /app/vault .
COPY go/templates ./templates
COPY go/static ./static
COPY go/.env.example .env
EXPOSE 8082
CMD ["./server"]

47
Dockerfile.prod.php Normal file
View file

@ -0,0 +1,47 @@
# Use the official PHP 5.6 Apache image for classic mod_php
FROM php:5.6-apache
# Install required system libraries and PHP extensions for CakePHP
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
apt-get update && \
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Copy vhost config to Apache's sites-available
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime \
&& a2ensite cmc-sales \
&& a2dissite 000-default \
&& a2enmod rewrite \
&& a2enmod headers
# Copy site into place.
ADD php/ /var/www/cmc-sales
ADD php/app/config/database.php /var/www/cmc-sales/app/config/database.php
RUN mkdir -p /var/www/cmc-sales/app/tmp
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
# Ensure CakePHP tmp directory is writable by web server
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

1
Dockerfile.stg.db Normal file
View file

@ -0,0 +1 @@
FROM mariadb:latest

21
Dockerfile.stg.go Normal file
View file

@ -0,0 +1,21 @@
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go/go.mod go/go.sum ./
RUN go mod download
COPY go/ .
RUN go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
RUN sqlc generate
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/server .
COPY go/templates ./templates
COPY go/static ./static
COPY go/.env.example .env
EXPOSE 8082
CMD ["./server"]

67
Dockerfile.stg.php Normal file
View file

@ -0,0 +1,67 @@
# Use the official PHP 5.6 Apache image for classic mod_php
FROM php:5.6-apache
# Install required system libraries and PHP extensions for CakePHP
RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
sed -i '/stretch-updates/d' /etc/apt/sources.list && \
echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure && \
echo 'Acquire::AllowDowngradeToInsecureRepositories "true";' >> /etc/apt/apt.conf.d/99allow-insecure && \
apt-get update && \
apt-get install --allow-unauthenticated -y libc-client2007e-dev libkrb5-dev libpng-dev libjpeg-dev libfreetype6-dev libcurl4-openssl-dev libxml2-dev libssl-dev libmcrypt-dev libicu-dev && \
docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ && \
docker-php-ext-configure imap --with-kerberos --with-imap-ssl && \
docker-php-ext-install mysqli pdo pdo_mysql mbstring gd curl imap
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Legacy apt compatibility and install steps for Ubuntu 16.04 (now commented out)
# RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://old-releases.ubuntu.com/ubuntu/|g' /etc/apt/sources.list && \
# sed -i 's|http://security.ubuntu.com/ubuntu|http://old-releases.ubuntu.com/ubuntu|g' /etc/apt/sources.list
# RUN apt-get update
# RUN apt-get -y upgrade
# RUN echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99allow-insecure
# RUN apt-get update -o Acquire::AllowInsecureRepositories=true --allow-unauthenticated
# RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
# RUN a2enmod php5
# RUN php5enmod openssl
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
# RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
# ADD conf/php.ini /etc/php5/apache2/php.ini
# Copy vhost config to Apache's sites-available
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales.conf
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime \
&& a2ensite cmc-sales \
&& a2dissite 000-default \
&& a2enmod rewrite \
&& a2enmod headers
# Copy site into place.
ADD php/ /var/www/cmc-sales
ADD php/app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
RUN mkdir -p /var/www/cmc-sales/app/tmp
RUN mkdir -p /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
# Ensure CakePHP tmp directory is writable by web server
RUN chmod -R 777 /var/www/cmc-sales/app/tmp
# No need to disable proxy_fcgi or remove PHP-FPM conf files in this image
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

View file

@ -1,63 +0,0 @@
# This is 99% the same as the prod one. I should do something smarter here.
FROM ubuntu:lucid
# Set environment variables.
ENV HOME /root
# Define working directory.
WORKDIR /root
RUN sed -i 's/archive/old-releases/' /etc/apt/sources.list
RUN apt-get update
RUN apt-get -y upgrade
# Install apache, PHP, and supplimentary programs. curl and lynx-cur are for debugging the container.
RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php5 php5-mysql php5-gd php-pear php-apc php5-curl php5-imap
# Enable apache mods.
#RUN php5enmod openssl
RUN a2enmod php5
RUN a2enmod rewrite
RUN a2enmod headers
# Update the PHP.ini file, enable <? ?> tags and quieten logging.
# RUN sed -i "s/short_open_tag = Off/short_open_tag = On/" /etc/php5/apache2/php.ini
#RUN sed -i "s/error_reporting = .*$/error_reporting = E_ERROR | E_WARNING | E_PARSE/" /etc/php5/apache2/php.ini
ADD conf/php.ini /etc/php5/apache2/php.ini
# Manually set up the apache environment variables
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_LOG_DIR /var/log/apache2
ENV APACHE_LOCK_DIR /var/lock/apache2
ENV APACHE_PID_FILE /var/run/apache2.pid
ARG COMMIT
ENV COMMIT_SHA=${COMMIT}
EXPOSE 80
# Update the default apache site with the config we created.
ADD conf/apache-vhost.conf /etc/apache2/sites-available/cmc-sales
ADD conf/ripmime /bin/ripmime
RUN chmod +x /bin/ripmime
RUN a2dissite 000-default
# Copy site into place.
ADD . /var/www/cmc-sales
ADD app/config/database_stg.php /var/www/cmc-sales/app/config/database.php
RUN mkdir /var/www/cmc-sales/app/tmp
RUN mkdir /var/www/cmc-sales/app/tmp/logs
RUN chmod -R 755 /var/www/cmc-sales/app/tmp
RUN chmod +x /var/www/cmc-sales/run_vault.sh
RUN a2ensite cmc-sales
# By default, simply start apache.
CMD /usr/sbin/apache2ctl -D FOREGROUND

View file

@ -1,18 +0,0 @@
# migration instructions
mysql -u cmc -p cmc < ~/migration/latest.sql
MariaDB [(none)]> CREATE USER 'cmc'@'172.17.0.2' IDENTIFIED BY 'somepass';
Query OK, 0 rows affected (0.00 sec)
MariaDB [(none)]> GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'172.17.0.2';
www-data@helios:~$ du -hcs vaultmsgs
64G vaultmsgs
64G total
www-data@helios:~$ du -hcs emails
192G emails
192G total
www-data@helios:~$

138
README.md
View file

@ -1,8 +1,22 @@
# cmc-sales
## Development Setup
CMC Sales is a business management system with two applications:
CMC Sales now runs both legacy CakePHP and modern Go applications side by side.
- **PHP Application**: CakePHP 1.2.5 (currently the primary application)
- **Go Application**: Go + HTMX (used for select features, growing)
**Future development should be done in the Go application wherever possible.**
## Architecture
Both applications:
- Share the same MariaDB database
- Run behind a shared Caddy reverse proxy with basic authentication
- Support staging and production environments on the same server
The PHP application currently handles most functionality, while the Go application is used for select screens and new features as they're developed.
## Development Setup
### Quick Start
@ -45,105 +59,53 @@ Both applications share the same database, allowing for gradual migration.
- **Go Application**: Requires Go 1.23+ (for latest sqlc)
- Alternative: Use `Dockerfile.go.legacy` with Go 1.21 and sqlc v1.26.0
## Deployment
## Install a new server
### Prerequisites
(TODO this is all likely out of date)
### Requirements
Debian or Ubuntu OS. These instructions written for Debian 9.9
Assumed pre-work:
Create a new VM with hostname newserver.cmctechnologies.com.au
Configure DNS appropriately. cmctechnologies.com.au zones is currently managed in Google Cloud DNS on Karl's account:
https://console.cloud.google.com/net-services/dns/zones/cmctechnologies?project=cmc-technologies&authuser=1&folder&organizationId
Will need to migrate that to CMC's GSuite account at some point.
1. Install ansible on your workstation
```
apt-get install ansible
```
2. Clone the playbooks
```
git clone git@gitlab.com:minimalist.software/cmc-playbooks.git
```
3. Execute the playbooks
The nginx config expects the site to be available at sales.cmctechnologies.com.au.
You'll need to add the hostname to config/nginx-site, if this isn't sales.cmctechnologies.com.au
The deployment scripts use SSH to connect to the server. Configure your SSH config (`~/.ssh/config`) with a host entry named `cmc` pointing to the correct server:
```
cd cmc-playbooks
# Add the hostname of your new server to the inventory.txt
ansible-playbook -i inventory.txt setup.yml
```
4. SSH to the new server and configure gitlab-runner
```
ssh newserver.cmctechnologies.com.au
sudo gitlab-runner register
```
5. SSH to the new server as cmc user
```
ssh cmc@newserver.cmctechnologies.com.au
Host cmc
HostName node0.prd.springupsoftware.com
User cmc
IdentityFile ~/.ssh/cmc
```
6. Add the SSH key to the cmc-sales repo on gitlab as a deploy key
https://gitlab.com/minimalist.software/cmc-sales/-/settings/repository
```
cmc@cmc:~$ cat .ssh/id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFIdoWVp2pGDb46ubW6jkfIpREMa/veD6xZVAtnj3WG1sX7NEUlQYq3RKbZ5CThlw6GKMSYoIsIqk7p6zSoJHGlJSLxoJ0edKflciMUFMTQrdm4T1USXsK+gd0C4DUCyVkYFOs37sy+JtziymnBTm7iOeVI3aMxwfoCOs6mNiD0ettjJT6WtVyy0ZTb6yU4uz7CHj1IGsvwsoKJWPGwJrZ/MfByNl6aJ8R/8zDwbtP06owKD4b3ZPgakM3nYRRoKzHZ/SClz50SXMKC4/nmFY9wLuuMhCWK+9x4/4VPSnxXESOlENMfUoa1IY4osAnZCtaFrWDyenJ+spZrNfgcscD ansible-generated on cmc
### Deployment Procedures
Deploy to staging or production using the scripts in the `deploy/` directory:
**Deploy to Staging:**
```bash
./scripts/deploy/deploy-stg.sh
```
6. Clone the cmc-sales repo
```
git clone git@gitlab.com:minimalist.software/cmc-sales.git
**Deploy to Production:**
```bash
./deploy/deploy-prod.sh
```
7. As root on new server configure mySQL user cmc
Note: get password from app/config/database.php
(or set a new one and change it)
```
# mysql -u root
CREATE USER 'cmc'@'localhost' IDENTIFIED BY 'password';
CREATE USER 'cmc'@'172.17.0.2' IDENTIFIED BY 'password';
CREATE database cmc;
GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'localhost';
GRANT ALL PRIVILEGES ON cmc.* TO 'cmc'@'172.17.0.2';
**Rebuild without cache (useful after dependency changes):**
```bash
./scripts/deploy/deploy-prod.sh --no-cache
./scripts/deploy/deploy-stg.sh --no-cache
```
8. Get the latest backup from Google Drive
### How Deployment Works
In the shared google drive:
eg. backups/database/backup_20191217_21001.sql.gz
1. The deploy script connects to the server via the `cmc` SSH host
2. Clones or updates the appropriate git branch (`stg` or `prod`)
3. Creates environment configuration for the Go application
4. Builds and starts Docker containers using the appropriate compose file
5. Applications are accessible through Caddy reverse proxy with basic auth
Copy up to the new server:
```
rsync backup_*.gz root@newserver:~/
### Deployment Environments
```
- **Staging**: Branch `stg` → https://stg.cmctechnologies.com.au
- **Production**: Branch `prod` → https://sales.cmctechnologies.com.au or https://prod.cmctechnologies.com.au
9. Restore backup to cmc database
```
zcat backup_* | mysql -u cmc -p
```
10. Redeploy from Gitlab
https://gitlab.com/minimalist.software/cmc-sales/pipelines/new
11. You should have a new installation of cmc-sales.
12. Seems new Linux kernels break the docker
https://github.com/moby/moby/issues/28705
13. Mysql needs special args not to break
```
# /etc/mysql/my.cnf
sql_mode=ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
```
Both environments run on the same server and share:
- A single Caddy reverse proxy (handles HTTPS and basic authentication for both environments)
- Separate Docker containers for each environment's PHP and Go applications
- Separate MariaDB database instances

View file

@ -1,46 +0,0 @@
<?php
/* SVN FILE: $Id: bootstrap.php 6311 2008-01-02 06:33:52Z phpnut $ */
/**
* Short description for file.
*
* Long description for file
*
* PHP versions 4 and 5
*
* CakePHP(tm) : Rapid Development Framework <http://www.cakephp.org/>
* Copyright 2005-2008, Cake Software Foundation, Inc.
* 1785 E. Sahara Avenue, Suite 490-204
* Las Vegas, Nevada 89104
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @filesource
* @copyright Copyright 2005-2008, Cake Software Foundation, Inc.
* @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
* @package cake
* @subpackage cake.app.config
* @since CakePHP(tm) v 0.10.8.2117
* @version $Revision: 6311 $
* @modifiedby $LastChangedBy: phpnut $
* @lastmodified $Date: 2008-01-01 22:33:52 -0800 (Tue, 01 Jan 2008) $
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
*/
/**
*
* This file is loaded automatically by the app/webroot/index.php file after the core bootstrap.php is loaded
* This is an application wide file to load any function that is not used within a class define.
* You can also use this to include or require any files in your application.
*
*/
/**
* The settings below can be used to set additional paths to models, views and controllers.
* This is related to Ticket #470 (https://trac.cakephp.org/ticket/470)
*
* $modelPaths = array('full path to models', 'second full path to models', 'etc...');
* $viewPaths = array('this path to views', 'second full path to views', 'etc...');
* $controllerPaths = array('this path to controllers', 'second full path to controllers', 'etc...');
*
*/
//EOF
?>

View file

@ -1,179 +0,0 @@
<?php
error_reporting(E_ALL & ~E_STRICT & ~E_DEPRECATED);
/* SVN FILE: $Id: core.php 7296 2008-06-27 09:09:03Z gwoo $ */
/**
* This is core configuration file.
*
* Use it to configure core behavior of Cake.
*
* PHP versions 4 and 5
*
* CakePHP(tm) : Rapid Development Framework <http://www.cakephp.org/>
* Copyright 2005-2008, Cake Software Foundation, Inc.
* 1785 E. Sahara Avenue, Suite 490-204
* Las Vegas, Nevada 89104
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @filesource
* @copyright Copyright 2005-2008, Cake Software Foundation, Inc.
* @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
* @package cake
* @subpackage cake.app.config
* @since CakePHP(tm) v 0.2.9
* @version $Revision: 7296 $
* @modifiedby $LastChangedBy: gwoo $
* @lastmodified $Date: 2008-06-27 02:09:03 -0700 (Fri, 27 Jun 2008) $
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
*/
/**
* CakePHP Debug Level:
*
* Production Mode:
* 0: No error messages, errors, or warnings shown. Flash messages redirect.
*
* Development Mode:
* 1: Errors and warnings shown, model caches refreshed, flash messages halted.
* 2: As in 1, but also with full debug messages and SQL output.
* 3: As in 2, but also with full controller dump.
*
* In production mode, flash messages redirect after a time interval.
* In development mode, you need to click the flash message to continue.
*/
Configure::write('debug', 0);
Configure::write('version', '1.0.1');
$host = $_SERVER['HTTP_HOST'];
/*Configure::write('smtp_settings', array(
'port' => '587',
'timeout' => '60',
'host' => 'smtp-relay.gmail.com',
'username' => 'sales',
'password' => 'S%s\'mMZ})MGsg$k!5N|mPSQ>}'
));
*/
Configure::write('smtp_settings', array(
'port' => '25',
'timeout' => '30',
'host' => '172.17.0.1'));
//Production/Staging Config
$production_hosts = array('cmc.lan', '192.168.0.7', 'cmcbeta.lan', 'office.cmctechnologies.com.au:5000');
$basedir = '/var/www/cmc-sales/app/';
Cache::config('default', array(
'engine' => 'File', //[required]
'duration'=> 3600, //[optional]
'probability'=> 100, //[optional]
'path' => '/home/cmc/cmc-sales/app/tmp/', //[optional] use system tmp directory - remember to use absolute path
'prefix' => 'cake_', //[optional] prefix every cache file with this string
'lock' => false, //[optional] use file locking
'serialize' => true,
));
Configure::write('email_directory', '/var/www/emails');
Configure::write('pdf_directory', $basedir.'/webroot/pdf/');
Configure::write('attachments_directory', $basedir.'/webroot/attachments_files/');
/**
* Application wide charset encoding
*/
Configure::write('App.encoding', 'UTF-8');
/**
* Turn off all caching application-wide.
*
*/
Configure::write('Cache.disable', true);
/**
* Defines the default error type when using the log() function. Used for
* differentiating error logging and debugging. Currently PHP supports LOG_DEBUG.
*/
define('LOG_ERROR', 1);
/**
* The preferred session handling method. Valid values:
*
* 'php' Uses settings defined in your php.ini.
* 'cake' Saves session files in CakePHP's /tmp directory.
* 'database' Uses CakePHP's database sessions.
*
* To define a custom session handler, save it at /app/config/<name>.php.
* Set the value of 'Session.save' to <name> to utilize it in CakePHP.
*
* To use database sessions, execute the SQL file found at /app/config/sql/sessions.sql.
*
*/
Configure::write('Session.save', 'database');
/**
* The name of the table used to store CakePHP database sessions.
*
* 'Session.save' must be set to 'database' in order to utilize this constant.
*
* The table name set here should *not* include any table prefix defined elsewhere.
*/
Configure::write('Session.table', 'cake_sessions');
/**
* The DATABASE_CONFIG::$var to use for database session handling.
*
* 'Session.save' must be set to 'database' in order to utilize this constant.
*/
Configure::write('Session.database', 'default');
/**
* The name of CakePHP's session cookie.
*/
Configure::write('Session.cookie', 'QUOTENIK');
/**
* Session time out time (in seconds).
* Actual value depends on 'Security.level' setting.
*/
Configure::write('Session.timeout', '324');
/**
* If set to false, sessions are not automatically started.
*/
Configure::write('Session.start', true);
/**
* When set to false, HTTP_USER_AGENT will not be checked
* in the session
*/
Configure::write('Session.checkAgent', true);
/**
* The level of CakePHP security. The session timeout time defined
* in 'Session.timeout' is multiplied according to the settings here.
* Valid values:
*
* 'high' Session timeout in 'Session.timeout' x 10
* 'medium' Session timeout in 'Session.timeout' x 100
* 'low' Session timeout in 'Session.timeout' x 300
*
* CakePHP session IDs are also regenerated between requests if
* 'Security.level' is set to 'high'.
*/
Configure::write('Security.level', 'medium');
/**
* A random string used in security hashing methods.
*/
Configure::write('Security.salt', 'uiPxR3MzVXAID5zucbxLdxP4TX33buPoCWZr4JfroGoaE57UQC');
/**
* The classname and database used in CakePHP's
* access control lists.
*/
Configure::write('Acl.classname', 'DbAcl');
Configure::write('Acl.database', 'default');

View file

@ -1,18 +0,0 @@
<?php
//CakePHP is pretty awful. I was so foolish.
class DATABASE_CONFIG {
var $default = array(
'driver' => 'mysql',
'persistent' => false,
'host' => '172.17.0.1',
'login' => 'cmc',
'password' => 'xVRQI&cA?7AU=hqJ!%au',
'database' => 'cmc',
'prefix' => '',
);
}

View file

@ -1,14 +0,0 @@
<?php
class DATABASE_CONFIG {
var $default = array(
'driver' => 'mysql',
'persistent' => false,
'host' => '172.17.0.1',
'login' => 'staging',
'password' => 'stagingmoopwoopVerySecure',
'database' => 'staging',
'prefix' => '',
);
}

View file

@ -1,143 +0,0 @@
<?php
/* App Controller */
class AppController extends Controller {
var $components = array('RequestHandler');
var $uses = array('User');
var $helpers = array('Javascript', 'Time', 'Html', 'Form');
function beforeFilter() {
// Find the user that matches the HTTP basic auth user
$user = $this->User->find('first', array('recursive' => 0, 'conditions' => array('User.username'=>$_SERVER["PHP_AUTH_USER"])));
$this->set("currentuser", $user);
if($this->RequestHandler->isAjax()) {
Configure::write('debug', 0);
}
}
/**
* Check if the current logged in user is an admin
* @return boolean
*/
function isAdmin() {
$currentuser = $this->getCurrentUser();
if($currentuser['access_level'] == 'admin') {
return true;
}
else {
return false;
}
}
function isManager() {
$currentuser = $this->getCurrentUser();
if($currentuser['access_level'] == 'manager') {
return true;
}
else {
return false;
}
}
/**
* Read the current logged in user.
* @return array - the currently logged in user.
*/
function getCurrentUser() {
$user = $this->User->find('first', array('recursive' => 0, 'conditions' => array('User.username'=>$_SERVER["PHP_AUTH_USER"])));
return $user;
}
/**
* Return the id of the current user. False if not logged in.
*/
function getCurrentUserID() {
$currentuser = $this->getCurrentUser();
if($currentuser) {
return $currentuser['User']['id'];
}
else {
return false;
}
}
function calculateTotals($document, $gst) {
$totals = array('subtotal'=>0, 'gst'=>0, 'total'=>0);
foreach($document['LineItem'] as $lineitem) {
if($lineitem['option'] == 1) {
$totals['subtotal'] = 'TBA';
$totals['total'] = 'TBA';
$totals['gst'] = 'TBA';
return $totals;
}
else {
$totals['subtotal'] += $lineitem['net_price'];
}
}
if($gst == 1) {
$totals['gst'] = 0.1*$totals['subtotal'];
}
$totals['total'] = $totals['gst'] + $totals['subtotal'];
return $totals;
}
function unset_keys($array, $keys) {
foreach($keys as $key ) {
$array[$key] = null;
}
return $array;
}
function unset_multiple_keys($array, $keys) {
foreach($array as $index => $item) {
$array[$index]['id'] = null;
$array[$index]['document_id'] = null;
$array[$index]['costing_id'] = null;
}
}
/**
*
* @param <type> $year
* @param <type> $prevYear
* @return <type>
*/
function getFirstDayFY($year,$prevYear = false) {
if($prevYear == false) {
return mktime(0,0,0,7,1,$year);
}
else {
return mktime(0,0,0,7,1,$year-1);
}
}
/**
*
* @param <type> $year
* @return <int>
*/
function getLastDayFY($year) {
return mktime(23,59,59,6,30,$year);
}
}
?>

View file

@ -1,117 +0,0 @@
<?php
class AttachmentsController extends AppController {
var $name = 'Attachments';
var $helpers = array('Html', 'Form','Number','Time');
var $paginate = array(
'limit' => 500,
'order' => array(
array('Principle.short_name' => 'asc'),
array('Attachment.name' => 'asc')
),
'conditions' => array('Attachment.archived' => 0)
);
function index() {
$this->Attachment->recursive = 1;
$this->set('attachments', $this->paginate());
}
function view($id = null) {
if (!$id) {
$this->Session->setFlash(__('Invalid Attachment.', true));
$this->redirect(array('action'=>'index'));
}
$this->set('attachment', $this->Attachment->read(null, $id));
$this->layout = 'pdf';
}
function archived() {
$this->set('archived', $this->Attachment->find('all', array('conditions'=> array('archived' => 1), 'order' => array(
array('Principle.short_name' => 'asc'),
array('Attachment.name' => 'asc')
))));
}
function add() {
if (!empty($this->data)) {
$attachment = $this->Attachment->process_attachment($this->data);
if(!$attachment) {
$this->Session->setFlash('The Attachment could not be saved. The filename exists');
}
else {
$this->Attachment->create();
if ($this->Attachment->save($attachment)) {
$this->Session->setFlash(__('The Attachment has been saved', true));
$this->redirect(array('action'=>'index'));
} else {
$this->Session->setFlash(__('The Attachment could not be saved. Please, try again.', true));
}
}
}
$principles = $this->Attachment->Principle->find('list');
$this->set(compact('products', 'principles'));
}
function edit($id = null) {
if (!$id && empty($this->data)) {
$this->Session->setFlash(__('Invalid Attachment', true));
$this->redirect(array('action'=>'index'));
}
if (!empty($this->data)) {
$attachment = $this->data;
$existing = $this->Attachment->find('first', array('conditions'=>array('Attachment.id' => $this->data['Attachment']['id'])));
//Process editing the PDFs.
if(!empty($this->data['Attachment']['file']['tmp_name'])) {
if(!empty($existing)) {
//Delete old file
unlink($existing['Attachment']['file']);
}
$attachment = $this->Attachment->process_attachment($this->data);
if(!$attachment) {
$this->Session->setFlash('The Attachment could not be saved. The filename exists');
}
}
else {
$attachment['Attachment']['file'] = $existing['Attachment']['file'];
}
if ($this->Attachment->save($attachment)) {
$this->Session->setFlash(__('The Attachment has been saved', true));
$this->redirect(array('action'=>'index'));
} else {
$this->Session->setFlash(__('The Attachment could not be saved. Please, try again.', true));
}
}
else {
$this->data = $this->Attachment->read(null, $id);
}
$principles = $this->Attachment->Principle->find('list');
$this->set(compact('principles'));
}
function delete($id = null) {
if (!$id) {
$this->Session->setFlash(__('Invalid id for Attachment', true));
$this->redirect(array('action'=>'index'));
}
$attachment = $this->Attachment->read(null, $id);
if ($this->Attachment->del($id)) {
unlink($attachment['Attachment']['file']);
$this->Session->setFlash(__('Attachment deleted', true));
$this->redirect(array('action'=>'index'));
}
}
}
?>

File diff suppressed because it is too large Load diff

View file

@ -1,75 +0,0 @@
<?php
class EmailAttachmentsController extends AppController {
var $name = 'EmailAttachments';
var $helpers = array('Html', 'Form');
function download($id) {
// maybe make this GET a go webservice that's something like
// GET vault.cmctechnologies.com.au/filename
// HTTP basic auth, or some sort of preshared key
// service hits S3 if required. Cached on disk for $sometime
$file = $this->EmailAttachment->findById($id);
$file_path = Configure::read('email_directory');
if(file_exists($file_path."/".$file['EmailAttachment']['name'])) {
Configure::write('debug', 0);
if(!$file['EmailAttachment']['filename']) {
$filename = 'vault_'.time();
}
else {
$filename = $file['EmailAttachment']['filename'];
}
header('Content-type: ' . $file['EmailAttachment']['type']);
header('Content-length: ' . $file['EmailAttachment']['size']);
header('Content-Disposition: attachment; filename='.$filename);
readfile($file_path."/".$file['EmailAttachment']['name']);
exit();
}
else {
echo "ERROR!! : File Not Found";
echo $file['EmailAttachment']['filename'];
die();
}
}
function view($id = null) {
Configure::write('debug', 0);
$this->layout = 'minimal';
if(!$id) {
return;
}
else {
$file = $this->EmailAttachment->find('first', array('conditions'=>array('EmailAttachment.id'=>$id)));
//$this->set('attachment', $file);
$file_path = Configure::read('email_directory');
$contents = file_get_contents($file_path."/".$file['EmailAttachment']['name']);
if($file['EmailAttachment']['type'] == 'text/plain') {
$contents = nl2br($contents, true);
}
$this->set('contents', $contents);
}
}
}

View file

@ -1,14 +0,0 @@
<div class="attachments form">
<?php echo $form->create('Attachment', array('type'=>'file'));?>
<fieldset>
<legend><?php __('Add Attachment');?></legend>
<?php
echo $form->input('principle_id');
echo $form->input('name');
echo $form->file('file');
echo $form->input('description');
echo $form->input('archived');
?>
</fieldset>
<?php echo $form->end('Submit');?>
</div>

View file

@ -1,226 +0,0 @@
<!DOCTYPE html>
<?php // Quotenik 1.2 - Default Layout. Some Markup retained from CakePHP Default ?>
<html lang="en">
<head>
<meta charset="utf-8">
<title>
<?php __('CMC'); ?>
<?php echo $title_for_layout; ?>
</title>
<?php
$debugLevel = Configure::read('debug');
echo $html->meta('icon');
echo $html->css('quotenik');
echo $html->css('jquery-ui.custom.css');
//echo $javascript->link('ckeditor/ckeditor');
echo $javascript->link('jquery');
echo $javascript->link('jquery-ui');
echo $javascript->link('jquery.form');
echo $javascript->link('menu');
echo $javascript->link('global');
echo $javascript->link('search');
echo $javascript->link('jquery.jeditable.mini');
echo $javascript->link('jquery.validate');
echo $scripts_for_layout;
?>
</head>
<body>
<div id="container">
<div id="header">
<h3 id="logo">CMC Technologies</h3>
<div id="username">
<?php
$logoutlink = $html->link('Logout', '/users/logout');
echo $html->link($currentuser['User']['username'], '/users/view/' . $currentuser['User']['id']);
?>
</div>
<div id="navdiv">
<ul id="nav">
<li><?php echo $html->link('Enquiries', '/enquiries/index'); ?>
<ul>
<li class="last"><?php echo $html->link('Enquiry Register', '/enquiries/index'); ?></li>
</ul>
</li>
<li><?php echo $html->link('Documents', '/documents/index'); ?>
<ul>
<li class="last"><?php echo $html->link('Documents Index', '/documents/index'); ?></li>
</ul>
</li>
<li><?php echo $html->link('Jobs', '/jobs/index'); ?>
<ul>
<?
if ($currentuser['User']['access_level'] == 'manager' || $currentuser['User']['access_level'] == 'admin'):
?>
<li><?= $html->link('Reports', '/jobs/reports'); ?></li>
<? endif; ?>
<li class="last"><?php echo $html->link('Job List', '/jobs/index'); ?></li>
</ul>
</li>
<li><?php echo $html->link('Shipments', '/shipments/index'); ?>
<ul>
<li class=""><?php echo $html->link('All Shipments', '/shipments/index'); ?></li>
<li class=""><?php echo $html->link('Import Shipments', '/shipments/index/import'); ?></li>
<li class=""><?php echo $html->link('Direct Shipments', '/shipments/index/direct'); ?></li>
<li class=""><?php echo $html->link('Export Shipments', '/shipments/index/export'); ?></li>
<li class=""><?php echo $html->link('Local Shipments', '/shipments/index/local'); ?></li>
<li class=""><?php echo $html->link('Monthly Deferred GST', '/shipments/reports'); ?></li>
<li class="last"><?php echo $html->link('Freight Forwarders', '/freight_forwarders'); ?></li>
</ul>
</li>
<li><?php echo $html->link('Customers', '/customers/index'); ?>
<ul>
<li><?php echo $html->link('Customer Index', '/customers/index'); ?></li>
<li><?php echo $html->link('Add Customer', '/customers/add'); ?></li>
<li class="last"><?php echo $html->link('Industries', '/industries/index'); ?></li>
</ul>
</li>
<li><?php echo $html->link('POs', '/purchase_orders/index'); ?>
<ul>
<li class="last"><?php echo $html->link('PO Index', '/purchase_orders/index'); ?></li>
</ul>
</li>
<li><?php echo $html->link('Invoices', '/invoices/index'); ?>
<ul>
<li class=""><?php echo $html->link('Invoices Index', '/invoices/index'); ?></li>
<li class="last"><?php echo $html->link('Print View', '/invoices/printView'); ?></li>
</ul>
</li>
<li><?php echo $html->link('Products', '/products/index'); ?>
<ul>
<li><?php echo $html->link('Product Index', '/products/index'); ?></li>
<li class="last"><?php echo $html->link('Add Product', '/products/add'); ?></li>
</ul>
</li>
<li><?php echo $html->link('Principles', '/principles/index'); ?>
<ul>
<li><?php echo $html->link('Principle Index', '/principles/index'); ?></li>
<li><?php echo $html->link('Attachments', '/attachments/index'); ?></li>
<li class="last"><?php echo $html->link('Add Principle', '/principles/add'); ?></li>
</ul>
</li>
<?php /* <li> echo $html->link('Users', '/users/index'); ?>
<ul>
<li> echo $html->link('Users Index', '/users/index'); </li>
<li class="last"><?php echo $html->link('Add User', '/users/add'); </li>
</ul>
</li> */ ?>
<li><?php echo $html->link('Search', '/enquiries/search', array('id' => 'searchLink')); ?>
</li>
<li><?php echo $html->link('Help', '/pages/about'); ?>
<ul>
<li><a href="/pages/bug">Raise a bug</a></li>
<li><a href="https://gitlab.com/minimalist.software/cmc-sales/issues">Issue tracker</a></li>
<li class="last"><?php echo $html->link('About', '/pages/about'); ?></li>
</ul>
</li>
<?
if ($currentuser['User']['access_level'] == 'manager' || $currentuser['User']['access_level'] == 'admin'):
?>
<li class="extra"><?=$html->link('Admin', '/admin'); ?>
</li>
<? endif; ?>
</ul>
</div>
</div>
<div id="content">
<?php
if ($session->check('Message.flash')):
$session->flash();
endif;
?>
<div id="globalAjaxLoading">
<h3>Loading, please wait</h3>
<?php echo $html->image('ajax-loader.gif'); ?>
</div>
<?php echo $content_for_layout; ?>
</div>
<div id="footer">
Powered by CMC-Sales <a href="https://code.springupsoftware.com/cmc/cmc-sales/commit/<?= getenv("COMMIT_SHA"); ?>"><?= getenv("COMMIT_SHA"); ?></a>
</div>
</div>
<div id="dialogDiv">
<?php
echo $form->create('Enquiry', array('default' => false, 'id' => 'searchForm', 'default' => 'false'));
echo '<h2>Search';
echo $html->image('system-search.png');
echo '</h2>';
echo "<p>Search for: Enquiry Number, Job Number, Customer, Contact or Customer Order Number</p>";
echo $form->input('Enquiry.search_string', array('label' => false, 'id' => 'searchString'));
?>
<div class ="ui-widget">
<button id="searchButton">Search</button>
</div>
<?
echo $form->end();
?>
<div id="ajaxLoading">
<h2>Loading, please wait..</h2>
<?php echo $html->image('ajax-loader.gif'); ?>
</div>
<div id="results"></div>
</div>
<?php
$mem_usage = memory_get_usage(true);
if ($mem_usage < 1024)
echo $mem_usage . " bytes";
elseif ($mem_usage < 1048576)
echo round($mem_usage / 1024, 2) . " kilobytes";
else
echo round($mem_usage / 1048576, 2) . " megabytes";
echo "<br/>";
?>
<?php echo $cakeDebug; ?>
</body>
</html>

View file

@ -1,5 +0,0 @@
#!/bin/bash
FILENAME=backups/backup_$(date +'%Y%m%d-%H%M%S').sql.gz
mysqldump cmc | gzip > $FILENAME
rclone copy $FILENAME gdrivebackups:database/
rclone sync cmc-sales/app/webroot/pdf gdrivebackups:pdf/

View file

@ -1,3 +0,0 @@
ID=$(docker ps -f ancestor=cmc:latest -q)
docker kill $ID
docker build . -t "cmc:latest" --platform linux/amd64

View file

@ -1,3 +0,0 @@
ID=$(docker ps -f ancestor=cmc:stg -q)
docker kill $ID
docker build -f Dockerfile_stg . -t "cmc:stg"

View file

@ -1,797 +0,0 @@
<?php
/* SVN FILE: $Id$ */
/**
* Short description for file.
*
* Long description for file
*
* PHP versions 4 and 5
*
* CakePHP(tm) : Rapid Development Framework (http://www.cakephp.org)
* Copyright 2005-2008, Cake Software Foundation, Inc. (http://www.cakefoundation.org)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @filesource
* @copyright Copyright 2005-2008, Cake Software Foundation, Inc. (http://www.cakefoundation.org)
* @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
* @package cake
* @subpackage cake.cake.libs.controller.components
* @since CakePHP(tm) v 1.2.0.3467
* @version $Revision$
* @modifiedby $LastChangedBy$
* @lastmodified $Date$
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
*/
/**
* EmailComponent
*
* This component is used for handling Internet Message Format based
* based on the standard outlined in http://www.rfc-editor.org/rfc/rfc2822.txt
*
* @package cake
* @subpackage cake.cake.libs.controller.components
*
*/
App::import('Core', 'Multibyte');
class EmailComponent extends Object{
/**
* Recipient of the email
*
* @var string
* @access public
*/
var $to = null;
/**
* The mail which the email is sent from
*
* @var string
* @access public
*/
var $from = null;
/**
* The email the recipient will reply to
*
* @var string
* @access public
*/
var $replyTo = null;
/**
* The read receipt email
*
* @var string
* @access public
*/
var $readReceipt = null;
/**
* The mail that will be used in case of any errors like
* - Remote mailserver down
* - Remote user has exceeded his quota
* - Unknown user
*
* @var string
* @access public
*/
var $return = null;
/**
* Carbon Copy
*
* List of email's that should receive a copy of the email.
* The Recipient WILL be able to see this list
*
* @var array
* @access public
*/
var $cc = array();
/**
* Blind Carbon Copy
*
* List of email's that should receive a copy of the email.
* The Recipient WILL NOT be able to see this list
*
* @var array
* @access public
*/
var $bcc = array();
/**
* The subject of the email
*
* @var string
* @access public
*/
var $subject = null;
/**
* Associative array of a user defined headers
* Keys will be prefixed 'X-' as per RFC2822 Section 4.7.5
*
* @var array
* @access public
*/
var $headers = array();
/**
* List of additional headers
*
* These will NOT be used if you are using safemode and mail()
*
* @var string
* @access public
*/
var $additionalParams = null;
/**
* Layout for the View
*
* @var string
* @access public
*/
var $layout = 'default';
/**
* Template for the view
*
* @var string
* @access public
*/
var $template = null;
/**
* as per RFC2822 Section 2.1.1
*
* @var integer
* @access public
*/
var $lineLength = 70;
/**
* @deprecated see lineLength
*/
var $_lineLength = null;
/**
* What format should the email be sent in
*
* Supported formats:
* - text
* - html
* - both
*
* @var string
* @access public
*/
var $sendAs = 'text';
/**
* What method should the email be sent by
*
* Supported methods:
* - mail
* - smtp
* - debug
*
* @var string
* @access public
*/
var $delivery = 'mail';
/**
* charset the email is sent in
*
* @var string
* @access public
*/
var $charset = 'utf-8';
/**
* List of files that should be attached to the email.
*
* Can be both absolute and relative paths
*
* @var array
* @access public
*/
var $attachments = array();
/**
* What mailer should EmailComponent identify itself as
*
* @var string
* @access public
*/
var $xMailer = 'CakePHP Email Component';
/**
* The list of paths to search if an attachment isnt absolute
*
* @var array
* @access public
*/
var $filePaths = array();
/**
* List of options to use for smtp mail method
*
* Options is:
* - port
* - host
* - timeout
* - username
* - password
*
* @var array
* @access public
*/
var $smtpOptions = array(
'port'=> 25, 'host' => 'localhost', 'timeout' => 30
);
/**
* Placeholder for any errors that might happen with the
* smtp mail methods
*
* @var string
* @access public
*/
var $smtpError = null;
/**
* If set to true, the mail method will be auto-set to 'debug'
*
* @var string
* @access protected
*/
var $_debug = false;
/**
* Temporary store of message header lines
*
* @var array
* @access private
*/
var $__header = array();
/**
* If set, boundary to use for multipart mime messages
*
* @var string
* @access private
*/
var $__boundary = null;
/**
* Temporary store of message lines
*
* @var array
* @access private
*/
var $__message = array();
/**
* Variable that holds SMTP connection
*
* @var resource
* @access private
*/
var $__smtpConnection = null;
/**
* Initialize component
*
* @param object $controller Instantiating controller
* @access public
*/
function initialize(&$controller, $settings = array()) {
$this->Controller =& $controller;
if (Configure::read('App.encoding') !== null) {
$this->charset = Configure::read('App.encoding');
}
$this->_set($settings);
}
/**
* Startup component
*
* @param object $controller Instantiating controller
* @access public
*/
function startup(&$controller) {}
/**
* Send an email using the specified content, template and layout
*
* @param mixed $content Either an array of text lines, or a string with contents
* @param string $template Template to use when sending email
* @param string $layout Layout to use to enclose email body
* @return boolean Success
* @access public
*/
function send($content = null, $template = null, $layout = null) {
$this->__createHeader();
if ($template) {
$this->template = $template;
}
if ($layout) {
$this->layout = $layout;
}
if (is_array($content)) {
$content = implode("\n", $content) . "\n";
}
$message = $this->__wrap($content);
if ($this->template === null) {
$message = $this->__formatMessage($message);
} else {
$message = $this->__renderTemplate($message);
}
$message[] = '';
$this->__message = $message;
if (!empty($this->attachments)) {
$this->__attachFiles();
}
if (!is_null($this->__boundary)) {
$this->__message[] = '';
$this->__message[] = '--' . $this->__boundary . '--';
$this->__message[] = '';
}
if ($this->_debug) {
return $this->__debug();
}
$__method = '__' . $this->delivery;
$sent = $this->$__method();
$this->__header = array();
$this->__message = array();
return $sent;
}
/**
* Reset all EmailComponent internal variables to be able to send out a new email.
*
* @access public
*/
function reset() {
$this->template = null;
$this->to = null;
$this->from = null;
$this->replyTo = null;
$this->return = null;
$this->cc = array();
$this->bcc = array();
$this->subject = null;
$this->additionalParams = null;
$this->smtpError = null;
$this->attachments = array();
$this->__header = array();
$this->__boundary = null;
$this->__message = array();
}
/**
* Render the contents using the current layout and template.
*
* @param string $content Content to render
* @return array Email ready to be sent
* @access private
*/
function __renderTemplate($content) {
$viewClass = $this->Controller->view;
if ($viewClass != 'View') {
if (strpos($viewClass, '.') !== false) {
list($plugin, $viewClass) = explode('.', $viewClass);
}
$viewClass = $viewClass . 'View';
App::import('View', $this->Controller->view);
}
$View = new $viewClass($this->Controller, false);
$View->layout = $this->layout;
$msg = array();
$content = implode("\n", $content);
if ($this->sendAs === 'both') {
$htmlContent = $content;
if (!empty($this->attachments)) {
$msg[] = '--' . $this->__boundary;
$msg[] = 'Content-Type: multipart/alternative; boundary="alt-' . $this->__boundary . '"';
$msg[] = '';
}
$msg[] = '--alt-' . $this->__boundary;
$msg[] = 'Content-Type: text/plain; charset=' . $this->charset;
$msg[] = 'Content-Transfer-Encoding: 7bit';
$msg[] = '';
$content = $View->element('email' . DS . 'text' . DS . $this->template, array('content' => $content), true);
$View->layoutPath = 'email' . DS . 'text';
$content = explode("\n", str_replace(array("\r\n", "\r"), "\n", $View->renderLayout($content)));
$msg = array_merge($msg, $content);
$msg[] = '';
$msg[] = '--alt-' . $this->__boundary;
$msg[] = 'Content-Type: text/html; charset=' . $this->charset;
$msg[] = 'Content-Transfer-Encoding: 7bit';
$msg[] = '';
$htmlContent = $View->element('email' . DS . 'html' . DS . $this->template, array('content' => $htmlContent), true);
$View->layoutPath = 'email' . DS . 'html';
$htmlContent = explode("\n", str_replace(array("\r\n", "\r"), "\n", $View->renderLayout($htmlContent)));
$msg = array_merge($msg, $htmlContent);
$msg[] = '';
$msg[] = '--alt-' . $this->__boundary . '--';
$msg[] = '';
return $msg;
}
if (!empty($this->attachments)) {
if ($this->sendAs === 'html') {
$msg[] = '';
$msg[] = '--' . $this->__boundary;
$msg[] = 'Content-Type: text/html; charset=' . $this->charset;
$msg[] = 'Content-Transfer-Encoding: 7bit';
$msg[] = '';
} else {
$msg[] = '--' . $this->__boundary;
$msg[] = 'Content-Type: text/plain; charset=' . $this->charset;
$msg[] = 'Content-Transfer-Encoding: 7bit';
$msg[] = '';
}
}
$content = $View->element('email' . DS . $this->sendAs . DS . $this->template, array('content' => $content), true);
$View->layoutPath = 'email' . DS . $this->sendAs;
$content = explode("\n", str_replace(array("\r\n", "\r"), "\n", $View->renderLayout($content)));
$msg = array_merge($msg, $content);
return $msg;
}
/**
* Create unique boundary identifier
*
* @access private
*/
function __createBoundary() {
$this->__boundary = md5(uniqid(time()));
}
/**
* Create emails headers including (but not limited to) from email address, reply to,
* bcc and cc.
*
* @access private
*/
function __createHeader() {
if ($this->delivery == 'smtp') {
$this->__header[] = 'To: ' . $this->__formatAddress($this->to);
}
$this->__header[] = 'From: ' . $this->__formatAddress($this->from);
if (!empty($this->replyTo)) {
$this->__header[] = 'Reply-To: ' . $this->__formatAddress($this->replyTo);
}
if (!empty($this->return)) {
$this->__header[] = 'Return-Path: ' . $this->__formatAddress($this->return);
}
if (!empty($this->readReceipt)) {
$this->__header[] = 'Disposition-Notification-To: ' . $this->__formatAddress($this->readReceipt);
}
if (!empty($this->cc)) {
$this->__header[] = 'cc: ' .implode(', ', array_map(array($this, '__formatAddress'), $this->cc));
}
if (!empty($this->bcc) && $this->delivery != 'smtp') {
$this->__header[] = 'Bcc: ' .implode(', ', array_map(array($this, '__formatAddress'), $this->bcc));
}
if ($this->delivery == 'smtp') {
$this->__header[] = 'Subject: ' . $this->__encode($this->subject);
}
$this->__header[] = 'X-Mailer: ' . $this->xMailer;
if (!empty($this->headers)) {
foreach ($this->headers as $key => $val) {
$this->__header[] = 'X-' . $key . ': ' . $val;
}
}
if (!empty($this->attachments)) {
$this->__createBoundary();
$this->__header[] = 'MIME-Version: 1.0';
$this->__header[] = 'Content-Type: multipart/mixed; boundary="' . $this->__boundary . '"';
$this->__header[] = 'This part of the E-mail should never be seen. If';
$this->__header[] = 'you are reading this, consider upgrading your e-mail';
$this->__header[] = 'client to a MIME-compatible client.';
} elseif ($this->sendAs === 'text') {
$this->__header[] = 'Content-Type: text/plain; charset=' . $this->charset;
} elseif ($this->sendAs === 'html') {
$this->__header[] = 'Content-Type: text/html; charset=' . $this->charset;
} elseif ($this->sendAs === 'both') {
$this->__header[] = 'Content-Type: multipart/alternative; boundary="alt-' . $this->__boundary . '"';
}
$this->__header[] = 'Content-Transfer-Encoding: 7bit';
}
/**
* Format the message by seeing if it has attachments.
*
* @param string $message Message to format
* @access private
*/
function __formatMessage($message) {
if (!empty($this->attachments)) {
$prefix = array('--' . $this->__boundary);
if ($this->sendAs === 'text') {
$prefix[] = 'Content-Type: text/plain; charset=' . $this->charset;
} elseif ($this->sendAs === 'html') {
$prefix[] = 'Content-Type: text/html; charset=' . $this->charset;
} elseif ($this->sendAs === 'both') {
$prefix[] = 'Content-Type: multipart/alternative; boundary="alt-' . $this->__boundary . '"';
}
$prefix[] = 'Content-Transfer-Encoding: 7bit';
$prefix[] = '';
$message = array_merge($prefix, $message);
}
return $message;
}
/**
* Attach files by adding file contents inside boundaries.
*
* @access private
* @TODO: modify to use the core File class?
*/
function __attachFiles() {
$files = array();
foreach ($this->attachments as $attachment) {
$file = $this->__findFiles($attachment);
if (!empty($file)) {
$files[] = $file;
}
}
foreach ($files as $file) {
$handle = fopen($file, 'rb');
$data = fread($handle, filesize($file));
$data = chunk_split(base64_encode($data)) ;
fclose($handle);
$this->__message[] = '--' . $this->__boundary;
$this->__message[] = 'Content-Type: application/octet-stream';
$this->__message[] = 'Content-Transfer-Encoding: base64';
$this->__message[] = 'Content-Disposition: attachment; filename="' . basename($file) . '"';
$this->__message[] = '';
$this->__message[] = $data;
$this->__message[] = '';
}
}
/**
* Find the specified attachment in the list of file paths
*
* @param string $attachment Attachment file name to find
* @return string Path to located file
* @access private
*/
function __findFiles($attachment) {
if (file_exists($attachment)) {
return $attachment;
}
foreach ($this->filePaths as $path) {
if (file_exists($path . DS . $attachment)) {
$file = $path . DS . $attachment;
return $file;
}
}
return null;
}
/**
* Wrap the message using EmailComponent::$lineLength
*
* @param string $message Message to wrap
* @return array Wrapped message
* @access private
*/
function __wrap($message) {
$message = $this->__strip($message, true);
$message = str_replace(array("\r\n","\r"), "\n", $message);
$lines = explode("\n", $message);
$formatted = array();
if ($this->_lineLength !== null) {
trigger_error('_lineLength cannot be accessed please use lineLength', E_USER_WARNING);
$this->lineLength = $this->_lineLength;
}
foreach ($lines as $line) {
if (substr($line, 0, 1) == '.') {
$line = '.' . $line;
}
$formatted = array_merge($formatted, explode("\n", wordwrap($line, $this->lineLength, "\n", true)));
}
$formatted[] = '';
return $formatted;
}
/**
* Encode the specified string using the current charset
*
* @param string $subject String to encode
* @return string Encoded string
* @access private
*/
function __encode($subject) {
$subject = $this->__strip($subject);
$nl = "\r\n";
if ($this->delivery == 'mail') {
$nl = '';
}
return mb_encode_mimeheader($subject, $this->charset, 'B', $nl);
}
/**
* Format a string as an email address
*
* @param string $string String representing an email address
* @return string Email address suitable for email headers or smtp pipe
* @access private
*/
function __formatAddress($string, $smtp = false) {
if (strpos($string, '<') !== false) {
$value = explode('<', $string);
if ($smtp) {
$string = '<' . $value[1];
} else {
$string = $this->__encode($value[0]) . ' <' . $value[1];
}
}
return $this->__strip($string);
}
/**
* Remove certain elements (such as bcc:, to:, %0a) from given value
*
* @param string $value Value to strip
* @param boolean $message Set to true to indicate main message content
* @return string Stripped value
* @access private
*/
function __strip($value, $message = false) {
$search = '%0a|%0d|Content-(?:Type|Transfer-Encoding)\:';
$search .= '|charset\=|mime-version\:|multipart/mixed|(?:[^a-z]to|b?cc)\:.*';
if ($message !== true) {
$search .= '|\r|\n';
}
$search = '#(?:' . $search . ')#i';
while (preg_match($search, $value)) {
$value = preg_replace($search, '', $value);
}
return $value;
}
/**
* Wrapper for PHP mail function used for sending out emails
*
* @return bool Success
* @access private
*/
function __mail() {
$header = implode("\n", $this->__header);
$message = implode("\n", $this->__message);
if (ini_get('safe_mode')) {
return @mail($this->to, $this->__encode($this->subject), $message, $header);
}
return @mail($this->to, $this->__encode($this->subject), $message, $header, $this->additionalParams);
}
/**
* Sends out email via SMTP
*
* @return bool Success
* @access private
*/
function __smtp() {
App::import('Core', array('Socket'));
$this->__smtpConnection =& new CakeSocket(array_merge(array('protocol'=>'smtp'), $this->smtpOptions));
if (!$this->__smtpConnection->connect()) {
$this->smtpError = $this->__smtpConnection->lastError();
return false;
} elseif (!$this->__smtpSend(null, '220')) {
return false;
}
$httpHost = env('HTTP_HOST');
if (isset($this->smtpOptions['client'])) {
$host = $this->smtpOptions['client'];
} elseif (!empty($httpHost)) {
$host = $httpHost;
} else {
$host = 'localhost';
}
if (!$this->__smtpSend("HELO {$host}", '250')) {
return false;
}
if (isset($this->smtpOptions['username']) && isset($this->smtpOptions['password'])) {
$authRequired = $this->__smtpSend('AUTH LOGIN', '334|503');
if ($authRequired == '334') {
if (!$this->__smtpSend(base64_encode($this->smtpOptions['username']), '334')) {
return false;
}
if (!$this->__smtpSend(base64_encode($this->smtpOptions['password']), '235')) {
return false;
}
} elseif ($authRequired != '503') {
return false;
}
}
if (!$this->__smtpSend('MAIL FROM: ' . $this->__formatAddress($this->from, true))) {
return false;
}
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($this->to, true))) {
return false;
}
foreach ($this->cc as $cc) {
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($cc, true))) {
return false;
}
}
foreach ($this->bcc as $bcc) {
if (!$this->__smtpSend('RCPT TO: ' . $this->__formatAddress($bcc, true))) {
return false;
}
}
if (!$this->__smtpSend('DATA', '354')) {
return false;
}
$header = implode("\r\n", $this->__header);
$message = implode("\r\n", $this->__message);
if (!$this->__smtpSend($header . "\r\n\r\n" . $message . "\r\n\r\n\r\n.")) {
return false;
}
$this->__smtpSend('QUIT', false);
$this->__smtpConnection->disconnect();
return true;
}
/**
* Private method for sending data to SMTP connection
*
* @param string $data data to be sent to SMTP server
* @param mixed $checkCode code to check for in server response, false to skip
* @return bool Success
* @access private
*/
function __smtpSend($data, $checkCode = '250') {
if (!is_null($data)) {
$this->__smtpConnection->write($data . "\r\n");
}
if ($checkCode !== false) {
$response = $this->__smtpConnection->read();
if (preg_match('/^(' . $checkCode . ')/', $response, $code)) {
return $code[0];
}
$this->smtpError = $response;
return false;
}
return true;
}
/**
* Set as controller flash message a debug message showing current settings in component
*
* @return boolean Success
* @access private
*/
function __debug() {
$nl = "\n";
$header = implode($nl, $this->__header);
$message = implode($nl, $this->__message);
$fm = '<pre>';
if ($this->delivery == 'smtp') {
$fm .= sprintf('%s %s%s', 'Host:', $this->smtpOptions['host'], $nl);
$fm .= sprintf('%s %s%s', 'Port:', $this->smtpOptions['port'], $nl);
$fm .= sprintf('%s %s%s', 'Timeout:', $this->smtpOptions['timeout'], $nl);
}
$fm .= sprintf('%s %s%s', 'To:', $this->to, $nl);
$fm .= sprintf('%s %s%s', 'From:', $this->from, $nl);
$fm .= sprintf('%s %s%s', 'Subject:', $this->__encode($this->subject), $nl);
$fm .= sprintf('%s%3$s%3$s%s', 'Header:', $header, $nl);
$fm .= sprintf('%s%3$s%3$s%s', 'Parameters:', $this->additionalParams, $nl);
$fm .= sprintf('%s%3$s%3$s%s', 'Message:', $message, $nl);
$fm .= '</pre>';
$this->Controller->Session->setFlash($fm, 'default', null, 'email');
return true;
}
}
?>

View file

@ -1,4 +1,17 @@
NameVirtualHost *:80
<VirtualHost *:80>
DocumentRoot /var/www/cmc-sales/app/webroot
</VirtualHost>
<Directory /var/www/cmc-sales/app/webroot>
Options FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Send Apache logs to stdout/stderr for Docker
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
# Ensure PHP errors are also logged
php_flag log_errors on
php_value error_log /dev/stderr
</VirtualHost>

View file

@ -0,0 +1,3 @@
[mysqld]
# Custom sql_mode for legacy CakePHP compatibility
sql_mode = ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION

152
conf/nginx-production.conf Normal file
View file

@ -0,0 +1,152 @@
# Production environment configuration
upstream cmc_php_production {
server cmc-php-production:80;
keepalive 32;
}
upstream cmc_go_production {
server cmc-go-production:8080;
keepalive 32;
}
# Rate limiting
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
server {
server_name cmc.springupsoftware.com;
# Basic auth for production
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "CMC Sales - Restricted Access";
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Hide server information
server_tokens off;
# Request size limits
client_max_body_size 50M;
client_body_timeout 30s;
client_header_timeout 30s;
# Compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# CakePHP legacy app routes
location / {
limit_req zone=api burst=10 nodelay;
proxy_pass http://cmc_php_production;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Buffer settings for better performance
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Go API routes
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://cmc_go_production;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Buffer settings for better performance
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Go page routes for emails
location ~ ^/(emails|customers|products|purchase-orders|enquiries|documents) {
limit_req zone=api burst=15 nodelay;
proxy_pass http://cmc_go_production;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Buffer settings for better performance
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# Static files from Go app with aggressive caching
location /static/ {
proxy_pass http://cmc_go_production;
proxy_cache_valid 200 24h;
add_header Cache-Control "public, max-age=86400";
expires 1d;
}
# PDF files with caching
location /pdf/ {
proxy_pass http://cmc_go_production;
proxy_cache_valid 200 1h;
add_header Cache-Control "public, max-age=3600";
expires 1h;
}
# Health check endpoints (no rate limiting)
location /health {
proxy_pass http://cmc_go_production/api/v1/health;
access_log off;
}
# Block common attack patterns
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~ ~$ {
deny all;
access_log off;
log_not_found off;
}
# Error pages
error_page 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# Custom error page for rate limiting
error_page 429 /429.html;
location = /429.html {
root /usr/share/nginx/html;
}
listen 80;
}

137
conf/nginx-proxy.conf Normal file
View file

@ -0,0 +1,137 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Rate limiting
limit_req_zone $binary_remote_addr zone=global:10m rate=10r/s;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Upstream servers
upstream cmc_staging {
server nginx-staging:80;
keepalive 32;
}
upstream cmc_production {
server nginx-production:80;
keepalive 32;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name cmc.springupsoftware.com staging.cmc.springupsoftware.com;
# ACME challenge for Lego
location /.well-known/acme-challenge/ {
root /var/www/acme-challenge;
try_files $uri =404;
}
# Redirect all other traffic to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
# Production HTTPS
server {
listen 443 ssl http2;
server_name cmc.springupsoftware.com;
ssl_certificate /etc/ssl/certs/cmc.springupsoftware.com.crt;
ssl_certificate_key /etc/ssl/certs/cmc.springupsoftware.com.key;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
# Rate limiting
limit_req zone=global burst=20 nodelay;
location / {
proxy_pass http://cmc_production;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Buffer settings
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}
# Staging HTTPS
server {
listen 443 ssl http2;
server_name staging.cmc.springupsoftware.com;
ssl_certificate /etc/ssl/certs/staging.cmc.springupsoftware.com.crt;
ssl_certificate_key /etc/ssl/certs/staging.cmc.springupsoftware.com.key;
# Security headers (less strict for staging)
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-Environment "STAGING";
# Rate limiting (more lenient for staging)
limit_req zone=global burst=50 nodelay;
location / {
proxy_pass http://cmc_staging;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Buffer settings
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}
}

View file

@ -1,9 +1,23 @@
resolver 127.0.0.11 valid=10s;
server {
server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted";
client_max_body_size 200M;
location /go/ {
set $upstream_go cmc-go:8080;
proxy_pass http://$upstream_go;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://cmc-php:80;
set $upstream_php cmc-php:80;
proxy_pass http://$upstream_php;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

27
conf/nginx-site.prod.conf Normal file
View file

@ -0,0 +1,27 @@
server {
server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted";
client_max_body_size 200M;
location /go/ {
proxy_pass http://cmc-prod-go:8082;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://cmc-prod-php:80;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 0.0.0.0:80;
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

27
conf/nginx-site.stg.conf Normal file
View file

@ -0,0 +1,27 @@
server {
server_name cmclocal;
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "Restricted";
client_max_body_size 200M;
location /go/ {
proxy_pass http://cmc-stg-go:8082;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://cmc-stg-php:80;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 0.0.0.0:80;
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

89
conf/nginx-staging.conf Normal file
View file

@ -0,0 +1,89 @@
# Staging environment configuration
upstream cmc_php_staging {
server cmc-php-staging:80;
}
upstream cmc_go_staging {
server cmc-go-staging:8080;
}
server {
server_name staging.cmc.springupsoftware.com;
# Basic auth for staging
auth_basic_user_file /etc/nginx/userpasswd;
auth_basic "CMC Sales Staging - Restricted Access";
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Staging banner
add_header X-Environment "STAGING";
# CakePHP legacy app routes
location / {
proxy_pass http://cmc_php_staging;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Environment "staging";
}
# Go API routes
location /api/ {
proxy_pass http://cmc_go_staging;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Environment "staging";
}
# Go page routes for emails
location ~ ^/(emails|customers|products|purchase-orders|enquiries|documents) {
proxy_pass http://cmc_go_staging;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Environment "staging";
}
# Static files from Go app
location /static/ {
proxy_pass http://cmc_go_staging;
proxy_cache_valid 200 1h;
add_header Cache-Control "public, max-age=3600";
}
# PDF files
location /pdf/ {
proxy_pass http://cmc_go_staging;
proxy_cache_valid 200 1h;
add_header Cache-Control "public, max-age=3600";
}
# Health check endpoints
location /health {
proxy_pass http://cmc_go_staging/api/v1/health;
access_log off;
}
# Error pages
error_page 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
listen 80;
}

View file

@ -633,7 +633,8 @@ html_errors = Off
; empty.
; http://php.net/error-log
; Example:
error_log = /var/log/php_errors.log
; For Docker: Send errors to stderr so they appear in docker logs
error_log = /dev/stderr
; Log errors to syslog (Event Log on NT, not valid in Windows 95).
;error_log = syslog

118
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,118 @@
services:
postfix:
image: boky/postfix
restart: unless-stopped
container_name: cmc-prod-postfix
env_file:
- .env.prod
# Production: relay to Gmail SMTP
environment:
- ALLOWED_SENDER_DOMAINS=cmctechnologies.com.au
# Gmail SMTP relay settings
- RELAYHOST=smtp-relay.gmail.com
- RELAYHOST_PORT=587
# SMTP_USERNAME and SMTP_PASSWORD are loaded from .env.prod via env_file
- SMTP_TLS_SECURITY_LEVEL=encrypt
- SMTP_USE_TLS=yes
- SMTP_USE_STARTTLS=yes
# --- Mailpit relay (for testing only) ---
# - RELAYHOST=mailpit:1025
networks:
- cmc-prod-network
nginx:
image: nginx:latest
container_name: cmc-prod-nginx
hostname: nginx-prod
ports:
- "8080:80" # Production nginx on port 8080 to avoid conflict
volumes:
- ./conf/nginx-site.prod.conf:/etc/nginx/conf.d/cmc.conf
- ./userpasswd:/etc/nginx/userpasswd:ro
depends_on:
- cmc-prod-php
restart: unless-stopped
networks:
- cmc-prod-network
cmc-prod-php:
build:
context: .
dockerfile: Dockerfile.prod.php
container_name: cmc-prod-php
environment:
MAIL_HOST: postfix
MAIL_PORT: 25
DB_HOST: cmc-prod-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
GO_APP_HOST: cmc-prod-go:8082
volumes:
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
- /home/cmc/files/emails:/var/www/emails
- /home/cmc/files/vault:/var/www/vault
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
- ./userpasswd:/etc/nginx/userpasswd:ro
networks:
- cmc-prod-network
restart: unless-stopped
depends_on:
- cmc-prod-db
cmc-prod-go:
build:
context: .
dockerfile: Dockerfile.prod.go
container_name: cmc-prod-go
environment:
DB_HOST: cmc-prod-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
PORT: 8082
SMTP_HOST: postfix
SMTP_PORT: 25
SMTP_USER: ""
SMTP_PASS: ""
SMTP_FROM: "sales@cmctechnologies.com.au"
ports:
- "8083:8082"
volumes:
- /home/cmc/files/pdf:/root/webroot/pdf:ro
- /home/cmc/files/attachments_files:/root/webroot/attachments_files
- /home/cmc/files/emails:/var/www/emails
- /home/cmc/files/vault:/var/www/vault
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
networks:
- cmc-prod-network
restart: unless-stopped
depends_on:
- cmc-prod-db
cmc-prod-db:
build:
context: .
dockerfile: Dockerfile.prod.db
container_name: cmc-prod-db
environment:
MYSQL_ROOT_PASSWORD: secureRootPassword
MYSQL_DATABASE: cmc
MYSQL_USER: cmc
MYSQL_PASSWORD: xVRQI&cA?7AU=hqJ!%au
volumes:
- db_data:/var/lib/mysql
ports:
- "3307:3306"
networks:
- cmc-prod-network
networks:
cmc-stg-network:
cmc-prod-network:
volumes:
db_data:

126
docker-compose.stg.yml Normal file
View file

@ -0,0 +1,126 @@
services:
postfix:
image: boky/postfix
restart: unless-stopped
container_name: cmc-stg-postfix
# Staging: relay to Mailpit (no authentication required)
environment:
- RELAYHOST=mailpit:1025
- ALLOWED_SENDER_DOMAINS=cmctechnologies.com.au
# --- Gmail SMTP relay settings (uncomment for production) ---
# - RELAYHOST=smtp-relay.gmail.com
# - RELAYHOST_PORT=587
# - SMTP_USERNAME=sales
# - SMTP_PASSWORD=S%s'mMZ})MGsg$k!5N|mPSQ>
# - SMTP_TLS_SECURITY_LEVEL=encrypt
# - SMTP_USE_TLS=yes
# - SMTP_USE_STARTTLS=yes
networks:
- cmc-stg-network
nginx:
image: nginx:latest
container_name: cmc-stg-nginx
hostname: nginx-stg
ports:
- "8081:80" # Staging nginx on different port
volumes:
- ./conf/nginx-site.stg.conf:/etc/nginx/conf.d/cmc.conf
- ./userpasswd:/etc/nginx/userpasswd:ro
depends_on:
- cmc-stg-php
restart: unless-stopped
networks:
- cmc-stg-network
cmc-stg-php:
build:
context: .
dockerfile: Dockerfile.stg.php
container_name: cmc-stg-php
environment:
MAIL_HOST: postfix
MAIL_PORT: 25
DB_HOST: cmc-stg-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
GO_APP_HOST: cmc-stg-go:8082
volumes:
- /home/cmc/files/pdf:/var/www/cmc-sales/app/webroot/pdf
- /home/cmc/files/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
- /home/cmc/files/emails:/var/www/emails
- /home/cmc/files/vault:/var/www/vault
- /home/cmc/files/vaultmsgs:/var/www/vaultmsgs
- ./userpasswd:/etc/nginx/userpasswd:ro
networks:
- cmc-stg-network
restart: unless-stopped
depends_on:
- cmc-stg-db
cmc-stg-go:
build:
context: .
dockerfile: Dockerfile.stg.go
container_name: cmc-stg-go
environment:
DB_HOST: cmc-stg-db
DB_PORT: 3306
DB_USER: cmc
DB_PASSWORD: xVRQI&cA?7AU=hqJ!%au
DB_NAME: cmc
PORT: 8082
SMTP_HOST: postfix
SMTP_PORT: 25
SMTP_USER: ""
SMTP_PASS: ""
SMTP_FROM: "sales@cmctechnologies.com.au"
ports:
- "8082:8082"
volumes:
- /home/cmc/files/pdf:/root/webroot/pdf:ro
- /home/cmc/files/attachments_files:/root/webroot/attachments_files
networks:
- cmc-stg-network
restart: unless-stopped
depends_on:
- cmc-stg-db
cmc-stg-db:
build:
context: .
dockerfile: Dockerfile.stg.db
container_name: cmc-stg-db
environment:
MYSQL_ROOT_PASSWORD: secureRootPassword
MYSQL_DATABASE: cmc
MYSQL_USER: cmc
MYSQL_PASSWORD: xVRQI&cA?7AU=hqJ!%au
volumes:
- db_data:/var/lib/mysql
ports:
- "3308:3306"
networks:
- cmc-stg-network
mailpit:
image: axllent/mailpit:latest
container_name: mailpit
ports:
- "8025:8025" # Mailpit web UI
- "1025:1025" # SMTP
networks:
- cmc-stg-network
- cmc-sales-prod_cmc-prod-network
restart: unless-stopped
networks:
cmc-stg-network:
cmc-sales-prod_cmc-prod-network:
external: true
volumes:
db_data:

View file

@ -8,7 +8,10 @@ services:
- ./conf/nginx-site.conf:/etc/nginx/conf.d/cmc.conf
- ./userpasswd:/etc/nginx/userpasswd:ro
depends_on:
- cmc-php
cmc-php:
condition: service_started
cmc-go:
condition: service_started
restart: unless-stopped
networks:
- cmc-network
@ -16,30 +19,32 @@ services:
cmc-php:
build:
context: .
dockerfile: Dockerfile
dockerfile: Dockerfile.local.php
platform: linux/amd64
container_name: cmc-php
environment:
GO_APP_HOST: cmc-go:8080
depends_on:
- db
volumes:
- ./app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
- ./app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
- ./php/app/webroot/pdf:/var/www/cmc-sales/app/webroot/pdf
- ./php/app/webroot/attachments_files:/var/www/cmc-sales/app/webroot/attachments_files
networks:
- cmc-network
restart: unless-stopped
develop:
watch:
- action: rebuild
path: ./app
path: ./php/app
ignore:
- ./app/webroot/pdf
- ./app/webroot/attachments_files
- ./app/tmp
- ./php/app/webroot/pdf
- ./php/app/webroot/attachments_files
- ./php/app/tmp
- action: sync
path: ./app/webroot/css
path: ./php/app/webroot/css
target: /var/www/cmc-sales/app/webroot/css
- action: sync
path: ./app/webroot/js
path: ./php/app/webroot/js
target: /var/www/cmc-sales/app/webroot/js
db:
@ -60,7 +65,7 @@ services:
cmc-go:
build:
context: .
dockerfile: Dockerfile.go
dockerfile: Dockerfile.local.go
container_name: cmc-go
environment:
DB_HOST: db
@ -75,25 +80,12 @@ services:
ports:
- "8080:8080"
volumes:
- ./app/webroot/pdf:/root/webroot/pdf
- ./go:/app
- ./go/.air.toml:/root/.air.toml
- ./go/.env.example:/root/.env
networks:
- cmc-network
restart: unless-stopped
develop:
watch:
- action: rebuild
path: ./go-app
ignore:
- ./go-app/bin
- ./go-app/.env
- ./go-app/tmp
- "**/.*" # Ignore hidden files
- action: sync
path: ./go-app/templates
target: /app/templates
- action: sync
path: ./go-app/static
target: /app/static
volumes:
db_data:

View file

@ -1,12 +0,0 @@
# Database configuration
DB_HOST=localhost
DB_PORT=3306
DB_USER=cmc
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
DB_NAME=cmc
# Root database password (for dbshell-root)
DB_ROOT_PASSWORD=secureRootPassword
# Server configuration
PORT=8080

33
go-app/.gitignore vendored
View file

@ -1,33 +0,0 @@
# Binaries
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go vendor directory
vendor/
# Environment files
.env
# Generated sqlc files (optional - you may want to commit these)
# internal/cmc/db/*.go
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
*~
# OS specific files
.DS_Store
Thumbs.db

View file

@ -1,66 +0,0 @@
.PHONY: help
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: install
install: ## Install dependencies
@echo "Setting up private module configuration..."
go env -w GOPRIVATE=code.springupsoftware.com
go mod download
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
.PHONY: sqlc
sqlc: ## Generate Go code from SQL queries
sqlc generate
.PHONY: build
build: sqlc ## Build the application
go build -o bin/server cmd/server/main.go
.PHONY: run
run: ## Run the application
go run cmd/server/main.go
.PHONY: dev
dev: sqlc ## Run the application with hot reload (requires air)
air
.PHONY: test
test: ## Run tests
go test -v ./...
.PHONY: clean
clean: ## Clean build artifacts
rm -rf bin/
rm -rf internal/cmc/db/*.go
.PHONY: docker-build
docker-build: ## Build Docker image
docker build -t cmc-go:latest -f Dockerfile.go .
.PHONY: docker-run
docker-run: ## Run application in Docker
docker run --rm -p 8080:8080 --network=host cmc-go:latest
.PHONY: dbshell
dbshell: ## Connect to MariaDB database interactively
@echo "Connecting to MariaDB..."
@if [ -z "$$DB_PASSWORD" ]; then \
echo "Reading password from docker-compose environment..."; \
docker compose exec db mariadb -u cmc -p cmc; \
else \
docker compose exec -e MYSQL_PWD="$$DB_PASSWORD" db mariadb -u cmc cmc; \
fi
.PHONY: dbshell-root
dbshell-root: ## Connect to MariaDB as root user
@echo "Connecting to MariaDB as root..."
@if [ -z "$$DB_ROOT_PASSWORD" ]; then \
echo "Please set DB_ROOT_PASSWORD environment variable"; \
exit 1; \
else \
docker compose exec -e MYSQL_PWD="$$DB_ROOT_PASSWORD" db mariadb -u root; \
fi

View file

@ -1,290 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"os"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
// Database configuration
dbHost := getEnv("DB_HOST", "localhost")
dbPort := getEnv("DB_PORT", "3306")
dbUser := getEnv("DB_USER", "cmc")
dbPass := getEnv("DB_PASSWORD", "xVRQI&cA?7AU=hqJ!%au")
dbName := getEnv("DB_NAME", "cmc")
// Connect to database
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPass, dbHost, dbPort, dbName)
database, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer database.Close()
// Test database connection
if err := database.Ping(); err != nil {
log.Fatal("Failed to ping database:", err)
}
log.Println("Connected to database successfully")
// Create queries instance
queries := db.New(database)
// Initialize template manager
tmpl, err := templates.NewTemplateManager("templates")
if err != nil {
log.Fatal("Failed to initialize templates:", err)
}
// Create handlers
customerHandler := handlers.NewCustomerHandler(queries)
productHandler := handlers.NewProductHandler(queries)
purchaseOrderHandler := handlers.NewPurchaseOrderHandler(queries)
enquiryHandler := handlers.NewEnquiryHandler(queries)
documentHandler := handlers.NewDocumentHandler(queries)
pageHandler := handlers.NewPageHandler(queries, tmpl)
addressHandler := handlers.NewAddressHandler(queries)
attachmentHandler := handlers.NewAttachmentHandler(queries)
countryHandler := handlers.NewCountryHandler(queries)
statusHandler := handlers.NewStatusHandler(queries)
lineItemHandler := handlers.NewLineItemHandler(queries)
// Setup routes
r := mux.NewRouter()
// Static files
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// PDF files (matching CakePHP structure)
r.PathPrefix("/pdf/").Handler(http.StripPrefix("/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
// API routes
api := r.PathPrefix("/api/v1").Subrouter()
// Customer routes
api.HandleFunc("/customers", customerHandler.List).Methods("GET")
api.HandleFunc("/customers", customerHandler.Create).Methods("POST")
api.HandleFunc("/customers/{id}", customerHandler.Get).Methods("GET")
api.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
api.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
api.HandleFunc("/customers/search", customerHandler.Search).Methods("GET")
// Product routes
api.HandleFunc("/products", productHandler.List).Methods("GET")
api.HandleFunc("/products", productHandler.Create).Methods("POST")
api.HandleFunc("/products/{id}", productHandler.Get).Methods("GET")
api.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
api.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
api.HandleFunc("/products/search", productHandler.Search).Methods("GET")
// Purchase Order routes
api.HandleFunc("/purchase-orders", purchaseOrderHandler.List).Methods("GET")
api.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Get).Methods("GET")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
api.HandleFunc("/purchase-orders/search", purchaseOrderHandler.Search).Methods("GET")
// Enquiry routes
api.HandleFunc("/enquiries", enquiryHandler.List).Methods("GET")
api.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Get).Methods("GET")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
api.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
api.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
api.HandleFunc("/enquiries/search", enquiryHandler.Search).Methods("GET")
// Document routes
api.HandleFunc("/documents", documentHandler.List).Methods("GET")
api.HandleFunc("/documents", documentHandler.Create).Methods("POST")
api.HandleFunc("/documents/{id}", documentHandler.Get).Methods("GET")
api.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
api.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
api.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
api.HandleFunc("/documents/search", documentHandler.Search).Methods("GET")
// Address routes
api.HandleFunc("/addresses", addressHandler.List).Methods("GET")
api.HandleFunc("/addresses", addressHandler.Create).Methods("POST")
api.HandleFunc("/addresses/{id}", addressHandler.Get).Methods("GET")
api.HandleFunc("/addresses/{id}", addressHandler.Update).Methods("PUT")
api.HandleFunc("/addresses/{id}", addressHandler.Delete).Methods("DELETE")
api.HandleFunc("/addresses/customer/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
// Attachment routes
api.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
api.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
api.HandleFunc("/attachments", attachmentHandler.Create).Methods("POST")
api.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
api.HandleFunc("/attachments/{id}", attachmentHandler.Update).Methods("PUT")
api.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
// Country routes
api.HandleFunc("/countries", countryHandler.List).Methods("GET")
api.HandleFunc("/countries", countryHandler.Create).Methods("POST")
api.HandleFunc("/countries/{id}", countryHandler.Get).Methods("GET")
api.HandleFunc("/countries/{id}", countryHandler.Update).Methods("PUT")
api.HandleFunc("/countries/{id}", countryHandler.Delete).Methods("DELETE")
api.HandleFunc("/countries/complete", countryHandler.CompleteCountry).Methods("GET")
// Status routes
api.HandleFunc("/statuses", statusHandler.List).Methods("GET")
api.HandleFunc("/statuses", statusHandler.Create).Methods("POST")
api.HandleFunc("/statuses/{id}", statusHandler.Get).Methods("GET")
api.HandleFunc("/statuses/{id}", statusHandler.Update).Methods("PUT")
api.HandleFunc("/statuses/{id}", statusHandler.Delete).Methods("DELETE")
api.HandleFunc("/statuses/json/{selectedId}", statusHandler.JsonList).Methods("GET")
// Line Item routes
api.HandleFunc("/line-items", lineItemHandler.List).Methods("GET")
api.HandleFunc("/line-items", lineItemHandler.Create).Methods("POST")
api.HandleFunc("/line-items/{id}", lineItemHandler.Get).Methods("GET")
api.HandleFunc("/line-items/{id}", lineItemHandler.Update).Methods("PUT")
api.HandleFunc("/line-items/{id}", lineItemHandler.Delete).Methods("DELETE")
api.HandleFunc("/line-items/document/{documentID}/table", lineItemHandler.GetTable).Methods("GET")
// Health check
api.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}).Methods("GET")
// Recent activity endpoint
r.HandleFunc("/api/recent-activity", documentHandler.GetRecentActivity).Methods("GET")
// Page routes
r.HandleFunc("/", pageHandler.Home).Methods("GET")
// Customer pages
r.HandleFunc("/customers", pageHandler.CustomersIndex).Methods("GET")
r.HandleFunc("/customers/new", pageHandler.CustomersNew).Methods("GET")
r.HandleFunc("/customers/search", pageHandler.CustomersSearch).Methods("GET")
r.HandleFunc("/customers/{id}", pageHandler.CustomersShow).Methods("GET")
r.HandleFunc("/customers/{id}/edit", pageHandler.CustomersEdit).Methods("GET")
// Product pages
r.HandleFunc("/products", pageHandler.ProductsIndex).Methods("GET")
r.HandleFunc("/products/new", pageHandler.ProductsNew).Methods("GET")
r.HandleFunc("/products/search", pageHandler.ProductsSearch).Methods("GET")
r.HandleFunc("/products/{id}", pageHandler.ProductsShow).Methods("GET")
r.HandleFunc("/products/{id}/edit", pageHandler.ProductsEdit).Methods("GET")
// Purchase Order pages
r.HandleFunc("/purchase-orders", pageHandler.PurchaseOrdersIndex).Methods("GET")
r.HandleFunc("/purchase-orders/new", pageHandler.PurchaseOrdersNew).Methods("GET")
r.HandleFunc("/purchase-orders/search", pageHandler.PurchaseOrdersSearch).Methods("GET")
r.HandleFunc("/purchase-orders/{id}", pageHandler.PurchaseOrdersShow).Methods("GET")
r.HandleFunc("/purchase-orders/{id}/edit", pageHandler.PurchaseOrdersEdit).Methods("GET")
// Enquiry pages
r.HandleFunc("/enquiries", pageHandler.EnquiriesIndex).Methods("GET")
r.HandleFunc("/enquiries/new", pageHandler.EnquiriesNew).Methods("GET")
r.HandleFunc("/enquiries/search", pageHandler.EnquiriesSearch).Methods("GET")
r.HandleFunc("/enquiries/{id}", pageHandler.EnquiriesShow).Methods("GET")
r.HandleFunc("/enquiries/{id}/edit", pageHandler.EnquiriesEdit).Methods("GET")
// Document pages
r.HandleFunc("/documents", pageHandler.DocumentsIndex).Methods("GET")
r.HandleFunc("/documents/search", pageHandler.DocumentsSearch).Methods("GET")
r.HandleFunc("/documents/view/{id}", pageHandler.DocumentsView).Methods("GET")
r.HandleFunc("/documents/{id}", pageHandler.DocumentsShow).Methods("GET")
r.HandleFunc("/documents/pdf/{id}", documentHandler.GeneratePDF).Methods("GET")
// Address routes (matching CakePHP)
r.HandleFunc("/addresses", addressHandler.List).Methods("GET")
r.HandleFunc("/addresses/view/{id}", addressHandler.Get).Methods("GET")
r.HandleFunc("/addresses/add/{customerid}", addressHandler.Create).Methods("GET", "POST")
r.HandleFunc("/addresses/add_another/{increment}", addressHandler.AddAnother).Methods("GET")
r.HandleFunc("/addresses/remove_another/{increment}", addressHandler.RemoveAnother).Methods("GET")
r.HandleFunc("/addresses/edit/{id}", addressHandler.Update).Methods("GET", "POST")
r.HandleFunc("/addresses/customer_addresses/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
// Attachment routes (matching CakePHP)
r.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
r.HandleFunc("/attachments/view/{id}", attachmentHandler.Get).Methods("GET")
r.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
r.HandleFunc("/attachments/add", attachmentHandler.Create).Methods("GET", "POST")
r.HandleFunc("/attachments/edit/{id}", attachmentHandler.Update).Methods("GET", "POST")
r.HandleFunc("/attachments/delete/{id}", attachmentHandler.Delete).Methods("POST")
// Country routes (matching CakePHP)
r.HandleFunc("/countries", countryHandler.List).Methods("GET")
r.HandleFunc("/countries/view/{id}", countryHandler.Get).Methods("GET")
r.HandleFunc("/countries/add", countryHandler.Create).Methods("GET", "POST")
r.HandleFunc("/countries/edit/{id}", countryHandler.Update).Methods("GET", "POST")
r.HandleFunc("/countries/delete/{id}", countryHandler.Delete).Methods("POST")
r.HandleFunc("/countries/complete_country", countryHandler.CompleteCountry).Methods("GET")
// Status routes (matching CakePHP)
r.HandleFunc("/statuses", statusHandler.List).Methods("GET")
r.HandleFunc("/statuses/view/{id}", statusHandler.Get).Methods("GET")
r.HandleFunc("/statuses/add", statusHandler.Create).Methods("GET", "POST")
r.HandleFunc("/statuses/edit/{id}", statusHandler.Update).Methods("GET", "POST")
r.HandleFunc("/statuses/delete/{id}", statusHandler.Delete).Methods("POST")
r.HandleFunc("/statuses/json_list/{selectedId}", statusHandler.JsonList).Methods("GET")
// Line Item routes (matching CakePHP)
r.HandleFunc("/line_items/ajax_add", lineItemHandler.AjaxAdd).Methods("POST")
r.HandleFunc("/line_items/ajax_edit", lineItemHandler.AjaxEdit).Methods("POST")
r.HandleFunc("/line_items/ajax_delete/{id}", lineItemHandler.AjaxDelete).Methods("POST")
r.HandleFunc("/line_items/get_table/{documentID}", lineItemHandler.GetTable).Methods("GET")
r.HandleFunc("/line_items/edit/{id}", lineItemHandler.Update).Methods("GET", "POST")
r.HandleFunc("/line_items/add/{documentID}", lineItemHandler.Create).Methods("GET", "POST")
// HTMX endpoints
r.HandleFunc("/customers", customerHandler.Create).Methods("POST")
r.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
r.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
r.HandleFunc("/products", productHandler.Create).Methods("POST")
r.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
r.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
r.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
r.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
r.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
r.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
r.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
r.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
r.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
r.HandleFunc("/documents", documentHandler.Create).Methods("POST")
r.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
r.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
r.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
// Start server
port := getEnv("PORT", "8080")
log.Printf("Starting server on port %s", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatal("Failed to start server:", err)
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View file

@ -1,10 +0,0 @@
module code.springupsoftware.com/cmc/cmc-sales
go 1.23
require (
github.com/go-sql-driver/mysql v1.7.1
github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf v1.16.2
)

View file

@ -1,18 +0,0 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View file

@ -1,374 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"database/sql"
"database/sql/driver"
"fmt"
"time"
)
type DocumentsType string
const (
DocumentsTypeQuote DocumentsType = "quote"
DocumentsTypeInvoice DocumentsType = "invoice"
DocumentsTypePurchaseOrder DocumentsType = "purchaseOrder"
DocumentsTypeOrderAck DocumentsType = "orderAck"
DocumentsTypePackingList DocumentsType = "packingList"
)
func (e *DocumentsType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = DocumentsType(s)
case string:
*e = DocumentsType(s)
default:
return fmt.Errorf("unsupported scan type for DocumentsType: %T", src)
}
return nil
}
type NullDocumentsType struct {
DocumentsType DocumentsType `json:"documents_type"`
Valid bool `json:"valid"` // Valid is true if DocumentsType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullDocumentsType) Scan(value interface{}) error {
if value == nil {
ns.DocumentsType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.DocumentsType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullDocumentsType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.DocumentsType), nil
}
type UsersAccessLevel string
const (
UsersAccessLevelAdmin UsersAccessLevel = "admin"
UsersAccessLevelManager UsersAccessLevel = "manager"
UsersAccessLevelUser UsersAccessLevel = "user"
UsersAccessLevelNone UsersAccessLevel = "none"
)
func (e *UsersAccessLevel) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = UsersAccessLevel(s)
case string:
*e = UsersAccessLevel(s)
default:
return fmt.Errorf("unsupported scan type for UsersAccessLevel: %T", src)
}
return nil
}
type NullUsersAccessLevel struct {
UsersAccessLevel UsersAccessLevel `json:"users_access_level"`
Valid bool `json:"valid"` // Valid is true if UsersAccessLevel is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullUsersAccessLevel) Scan(value interface{}) error {
if value == nil {
ns.UsersAccessLevel, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.UsersAccessLevel.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullUsersAccessLevel) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.UsersAccessLevel), nil
}
type UsersType string
const (
UsersTypePrinciple UsersType = "principle"
UsersTypeContact UsersType = "contact"
UsersTypeUser UsersType = "user"
)
func (e *UsersType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = UsersType(s)
case string:
*e = UsersType(s)
default:
return fmt.Errorf("unsupported scan type for UsersType: %T", src)
}
return nil
}
type NullUsersType struct {
UsersType UsersType `json:"users_type"`
Valid bool `json:"valid"` // Valid is true if UsersType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullUsersType) Scan(value interface{}) error {
if value == nil {
ns.UsersType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.UsersType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullUsersType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.UsersType), nil
}
type Address struct {
ID int32 `json:"id"`
// Descriptive Name for this address
Name string `json:"name"`
// street or unit number and street name
Address string `json:"address"`
// Suburb / City
City string `json:"city"`
// State foreign Key
StateID int32 `json:"state_id"`
// Country foreign Key
CountryID int32 `json:"country_id"`
// Customer foreign key
CustomerID int32 `json:"customer_id"`
// either bill / ship / both
Type string `json:"type"`
Postcode string `json:"postcode"`
}
type Attachment struct {
ID int32 `json:"id"`
PrincipleID int32 `json:"principle_id"`
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
Name string `json:"name"`
Filename string `json:"filename"`
File string `json:"file"`
Type string `json:"type"`
Size int32 `json:"size"`
Description string `json:"description"`
Archived bool `json:"archived"`
}
type Box struct {
ID int32 `json:"id"`
ShipmentID int32 `json:"shipment_id"`
Length string `json:"length"`
Width string `json:"width"`
Height string `json:"height"`
Weight string `json:"weight"`
}
type Country struct {
ID int32 `json:"id"`
Name string `json:"name"`
}
type Customer struct {
ID int32 `json:"id"`
// Company Name
Name string `json:"name"`
TradingName string `json:"trading_name"`
Abn sql.NullString `json:"abn"`
Created time.Time `json:"created"`
Notes string `json:"notes"`
DiscountPricingPolicies string `json:"discount_pricing_policies"`
PaymentTerms string `json:"payment_terms"`
CustomerCategoryID int32 `json:"customer_category_id"`
Url string `json:"url"`
CountryID int32 `json:"country_id"`
}
type Document struct {
ID int32 `json:"id"`
Type DocumentsType `json:"type"`
Created time.Time `json:"created"`
UserID int32 `json:"user_id"`
DocPageCount int32 `json:"doc_page_count"`
// Either the Enquiry number, Invoice no, order ack. Convenient place to store this to save on queries
CmcReference string `json:"cmc_reference"`
PdfFilename string `json:"pdf_filename"`
PdfCreatedAt time.Time `json:"pdf_created_at"`
PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
ShippingDetails sql.NullString `json:"shipping_details"`
Revision int32 `json:"revision"`
BillTo sql.NullString `json:"bill_to"`
ShipTo sql.NullString `json:"ship_to"`
EmailSentAt time.Time `json:"email_sent_at"`
EmailSentByUserID int32 `json:"email_sent_by_user_id"`
}
type Enquiry struct {
ID int32 `json:"id"`
Created time.Time `json:"created"`
Submitted sql.NullTime `json:"submitted"`
// enquirynumber
Title string `json:"title"`
UserID int32 `json:"user_id"`
CustomerID int32 `json:"customer_id"`
ContactID int32 `json:"contact_id"`
ContactUserID int32 `json:"contact_user_id"`
StateID int32 `json:"state_id"`
CountryID int32 `json:"country_id"`
PrincipleID int32 `json:"principle_id"`
StatusID int32 `json:"status_id"`
Comments string `json:"comments"`
// Numeric Principle Code
PrincipleCode int32 `json:"principle_code"`
// GST applicable on this enquiry
Gst bool `json:"gst"`
BillingAddressID sql.NullInt32 `json:"billing_address_id"`
ShippingAddressID sql.NullInt32 `json:"shipping_address_id"`
// has the enquired been posted
Posted bool `json:"posted"`
EmailCount int32 `json:"email_count"`
InvoiceCount int32 `json:"invoice_count"`
JobCount int32 `json:"job_count"`
QuoteCount int32 `json:"quote_count"`
Archived int8 `json:"archived"`
}
type Invoice struct {
ID int32 `json:"id"`
// CMC Invoice Number String
Title string `json:"title"`
CustomerID int32 `json:"customer_id"`
}
type LineItem struct {
ID int32 `json:"id"`
ItemNumber string `json:"item_number"`
Option bool `json:"option"`
Quantity string `json:"quantity"`
Title string `json:"title"`
Description string `json:"description"`
DocumentID int32 `json:"document_id"`
ProductID sql.NullInt32 `json:"product_id"`
HasTextPrices bool `json:"has_text_prices"`
HasPrice int8 `json:"has_price"`
UnitPriceString sql.NullString `json:"unit_price_string"`
GrossPriceString sql.NullString `json:"gross_price_string"`
CostingID sql.NullInt32 `json:"costing_id"`
// Either fill this in or have a costing_id associated with this record
GrossUnitPrice sql.NullString `json:"gross_unit_price"`
NetUnitPrice sql.NullString `json:"net_unit_price"`
DiscountPercent sql.NullString `json:"discount_percent"`
DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
GrossPrice sql.NullString `json:"gross_price"`
NetPrice sql.NullString `json:"net_price"`
}
type Principle struct {
ID int32 `json:"id"`
Name string `json:"name"`
ShortName sql.NullString `json:"short_name"`
Code int32 `json:"code"`
}
type Product struct {
ID int32 `json:"id"`
// Principle FK
PrincipleID int32 `json:"principle_id"`
ProductCategoryID int32 `json:"product_category_id"`
// This must match the Title in the Excel Costing File
Title string `json:"title"`
Description string `json:"description"`
// Part or model number principle uses to identify this product
ModelNumber sql.NullString `json:"model_number"`
// %1% - first item, %2% , second item etc
ModelNumberFormat sql.NullString `json:"model_number_format"`
// Any notes about this product. Note displayed on quotes
Notes sql.NullString `json:"notes"`
// Stock or Ident
Stock bool `json:"stock"`
ItemCode string `json:"item_code"`
ItemDescription string `json:"item_description"`
}
type PurchaseOrder struct {
ID int32 `json:"id"`
IssueDate time.Time `json:"issue_date"`
DispatchDate time.Time `json:"dispatch_date"`
DateArrived time.Time `json:"date_arrived"`
// CMC PONumber
Title string `json:"title"`
PrincipleID int32 `json:"principle_id"`
PrincipleReference string `json:"principle_reference"`
DocumentID int32 `json:"document_id"`
CurrencyID sql.NullInt32 `json:"currency_id"`
OrderedFrom string `json:"ordered_from"`
Description string `json:"description"`
DispatchBy string `json:"dispatch_by"`
DeliverTo string `json:"deliver_to"`
ShippingInstructions string `json:"shipping_instructions"`
JobsText string `json:"jobs_text"`
FreightForwarderText string `json:"freight_forwarder_text"`
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"`
}
type State struct {
ID int32 `json:"id"`
Name string `json:"name"`
Shortform sql.NullString `json:"shortform"`
Enqform sql.NullString `json:"enqform"`
}
type Status struct {
ID int32 `json:"id"`
Name string `json:"name"`
Color sql.NullString `json:"color"`
}
type User struct {
ID int32 `json:"id"`
PrincipleID int32 `json:"principle_id"`
CustomerID int32 `json:"customer_id"`
Type UsersType `json:"type"`
AccessLevel UsersAccessLevel `json:"access_level"`
Username string `json:"username"`
Password string `json:"password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
JobTitle string `json:"job_title"`
Phone string `json:"phone"`
Mobile string `json:"mobile"`
Fax string `json:"fax"`
PhoneExtension string `json:"phone_extension"`
DirectPhone string `json:"direct_phone"`
Notes string `json:"notes"`
// Added by Vault. May or may not be a real person.
ByVault bool `json:"by_vault"`
// Disregard emails from this address in future.
Blacklisted bool `json:"blacklisted"`
Enabled bool `json:"enabled"`
Archived sql.NullBool `json:"archived"`
PrimaryContact bool `json:"primary_contact"`
}

View file

@ -1,127 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"database/sql"
)
type Querier interface {
ArchiveEnquiry(ctx context.Context, id int32) error
ArchiveUser(ctx context.Context, id int32) error
CountDocuments(ctx context.Context) (int64, error)
CountDocumentsByType(ctx context.Context, type_ DocumentsType) (int64, error)
CountEnquiries(ctx context.Context) (int64, error)
CountEnquiriesByPrinciple(ctx context.Context, principleCode int32) (int64, error)
CountEnquiriesByPrincipleAndState(ctx context.Context, arg CountEnquiriesByPrincipleAndStateParams) (int64, error)
CountEnquiriesByStatus(ctx context.Context, statusID int32) (int64, error)
CreateAddress(ctx context.Context, arg CreateAddressParams) (sql.Result, error)
CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (sql.Result, error)
CreateBox(ctx context.Context, arg CreateBoxParams) (sql.Result, error)
CreateCountry(ctx context.Context, name string) (sql.Result, error)
CreateCustomer(ctx context.Context, arg CreateCustomerParams) (sql.Result, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (sql.Result, error)
CreateEnquiry(ctx context.Context, arg CreateEnquiryParams) (sql.Result, error)
CreateLineItem(ctx context.Context, arg CreateLineItemParams) (sql.Result, error)
CreatePrinciple(ctx context.Context, arg CreatePrincipleParams) (sql.Result, error)
CreateProduct(ctx context.Context, arg CreateProductParams) (sql.Result, error)
CreatePurchaseOrder(ctx context.Context, arg CreatePurchaseOrderParams) (sql.Result, error)
CreateState(ctx context.Context, arg CreateStateParams) (sql.Result, error)
CreateStatus(ctx context.Context, arg CreateStatusParams) (sql.Result, error)
CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error)
DeleteAddress(ctx context.Context, id int32) error
DeleteAttachment(ctx context.Context, id int32) error
DeleteBox(ctx context.Context, id int32) error
DeleteCountry(ctx context.Context, id int32) error
DeleteCustomer(ctx context.Context, id int32) error
DeleteDocument(ctx context.Context, id int32) error
DeleteLineItem(ctx context.Context, id int32) error
DeleteProduct(ctx context.Context, id int32) error
DeletePurchaseOrder(ctx context.Context, id int32) error
DeleteState(ctx context.Context, id int32) error
DeleteStatus(ctx context.Context, id int32) error
GetAddress(ctx context.Context, id int32) (Address, error)
GetAllCountries(ctx context.Context) ([]Country, error)
GetAllPrinciples(ctx context.Context) ([]Principle, error)
GetAllStates(ctx context.Context) ([]State, error)
GetAllStatuses(ctx context.Context) ([]GetAllStatusesRow, error)
GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error)
GetAttachment(ctx context.Context, id int32) (Attachment, error)
GetBox(ctx context.Context, id int32) (Box, error)
GetCountry(ctx context.Context, id int32) (Country, error)
GetCustomer(ctx context.Context, id int32) (Customer, error)
GetCustomerAddresses(ctx context.Context, customerID int32) ([]GetCustomerAddressesRow, error)
GetCustomerByABN(ctx context.Context, abn sql.NullString) (Customer, error)
GetDocument(ctx context.Context, id int32) (Document, error)
GetDocumentWithUser(ctx context.Context, id int32) (GetDocumentWithUserRow, error)
GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error)
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
GetLineItem(ctx context.Context, id int32) (LineItem, error)
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
GetMaxItemNumber(ctx context.Context, documentID int32) (interface{}, error)
GetPrinciple(ctx context.Context, id int32) (Principle, error)
GetPrincipleProducts(ctx context.Context, principleID int32) ([]Product, error)
GetProduct(ctx context.Context, id int32) (Product, error)
GetProductByItemCode(ctx context.Context, itemCode string) (Product, error)
GetProductsByCategory(ctx context.Context, arg GetProductsByCategoryParams) ([]Product, error)
GetPurchaseOrder(ctx context.Context, id int32) (PurchaseOrder, error)
GetPurchaseOrderByDocumentID(ctx context.Context, documentID int32) (PurchaseOrder, error)
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
GetState(ctx context.Context, id int32) (State, error)
GetStatus(ctx context.Context, id int32) (Status, error)
GetUser(ctx context.Context, id int32) (GetUserRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error)
ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)
ListArchivedEnquiries(ctx context.Context, arg ListArchivedEnquiriesParams) ([]ListArchivedEnquiriesRow, error)
ListAttachments(ctx context.Context, arg ListAttachmentsParams) ([]Attachment, error)
ListAttachmentsByPrinciple(ctx context.Context, principleID int32) ([]Attachment, error)
ListBoxes(ctx context.Context, arg ListBoxesParams) ([]Box, error)
ListBoxesByShipment(ctx context.Context, shipmentID int32) ([]Box, error)
ListCountries(ctx context.Context, arg ListCountriesParams) ([]Country, error)
ListCustomers(ctx context.Context, arg ListCustomersParams) ([]Customer, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]Document, error)
ListDocumentsByType(ctx context.Context, arg ListDocumentsByTypeParams) ([]Document, error)
ListEnquiries(ctx context.Context, arg ListEnquiriesParams) ([]ListEnquiriesRow, error)
ListLineItems(ctx context.Context, arg ListLineItemsParams) ([]LineItem, error)
ListLineItemsByDocument(ctx context.Context, documentID int32) ([]LineItem, error)
ListPrinciples(ctx context.Context, arg ListPrinciplesParams) ([]Principle, error)
ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error)
ListPurchaseOrders(ctx context.Context, arg ListPurchaseOrdersParams) ([]PurchaseOrder, error)
ListStates(ctx context.Context, arg ListStatesParams) ([]State, error)
ListStatuses(ctx context.Context, arg ListStatusesParams) ([]Status, error)
MarkEnquirySubmitted(ctx context.Context, arg MarkEnquirySubmittedParams) error
SearchCountriesByName(ctx context.Context, concat interface{}) ([]Country, error)
SearchCustomersByName(ctx context.Context, arg SearchCustomersByNameParams) ([]Customer, error)
SearchEnquiries(ctx context.Context, arg SearchEnquiriesParams) ([]SearchEnquiriesRow, error)
SearchProductsByTitle(ctx context.Context, arg SearchProductsByTitleParams) ([]Product, error)
SearchPurchaseOrdersByTitle(ctx context.Context, arg SearchPurchaseOrdersByTitleParams) ([]PurchaseOrder, error)
UnarchiveEnquiry(ctx context.Context, id int32) error
UnarchiveUser(ctx context.Context, id int32) error
UpdateAddress(ctx context.Context, arg UpdateAddressParams) error
UpdateAttachment(ctx context.Context, arg UpdateAttachmentParams) error
UpdateBox(ctx context.Context, arg UpdateBoxParams) error
UpdateCountry(ctx context.Context, arg UpdateCountryParams) error
UpdateCustomer(ctx context.Context, arg UpdateCustomerParams) error
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) error
UpdateEnquiry(ctx context.Context, arg UpdateEnquiryParams) error
UpdateEnquiryStatus(ctx context.Context, arg UpdateEnquiryStatusParams) error
UpdateLineItem(ctx context.Context, arg UpdateLineItemParams) error
UpdateLineItemPrices(ctx context.Context, arg UpdateLineItemPricesParams) error
UpdatePrinciple(ctx context.Context, arg UpdatePrincipleParams) error
UpdateProduct(ctx context.Context, arg UpdateProductParams) error
UpdatePurchaseOrder(ctx context.Context, arg UpdatePurchaseOrderParams) error
UpdateState(ctx context.Context, arg UpdateStateParams) error
UpdateStatus(ctx context.Context, arg UpdateStatusParams) error
UpdateUser(ctx context.Context, arg UpdateUserParams) error
}
var _ Querier = (*Queries)(nil)

View file

@ -1,251 +0,0 @@
package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"github.com/gorilla/mux"
)
type AttachmentHandler struct {
queries *db.Queries
}
func NewAttachmentHandler(queries *db.Queries) *AttachmentHandler {
return &AttachmentHandler{queries: queries}
}
func (h *AttachmentHandler) List(w http.ResponseWriter, r *http.Request) {
limit := 50
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if val, err := strconv.Atoi(l); err == nil {
limit = val
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if val, err := strconv.Atoi(o); err == nil {
offset = val
}
}
attachments, err := h.queries.ListAttachments(r.Context(), db.ListAttachmentsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(attachments)
}
func (h *AttachmentHandler) Archived(w http.ResponseWriter, r *http.Request) {
limit := 50
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if val, err := strconv.Atoi(l); err == nil {
limit = val
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if val, err := strconv.Atoi(o); err == nil {
offset = val
}
}
attachments, err := h.queries.ListArchivedAttachments(r.Context(), db.ListArchivedAttachmentsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(attachments)
}
func (h *AttachmentHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
return
}
attachment, err := h.queries.GetAttachment(r.Context(), int32(id))
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Attachment not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(attachment)
}
func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
// Parse multipart form
err := r.ParseMultipartForm(32 << 20) // 32MB max
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Get file from form
file, handler, err := r.FormFile("file")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
// Generate unique filename
ext := filepath.Ext(handler.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), handler.Filename[:len(handler.Filename)-len(ext)], ext)
// Create attachments directory if it doesn't exist
attachDir := "webroot/attachments_files"
if err := os.MkdirAll(attachDir, 0755); err != nil {
http.Error(w, "Failed to create attachments directory", http.StatusInternalServerError)
return
}
// Save file to disk
filepath := filepath.Join(attachDir, filename)
dst, err := os.Create(filepath)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Parse principle_id
principleID := 1 // Default
if pid := r.FormValue("principle_id"); pid != "" {
if id, err := strconv.Atoi(pid); err == nil {
principleID = id
}
}
// Create database record
params := db.CreateAttachmentParams{
PrincipleID: int32(principleID),
Name: r.FormValue("name"),
Filename: handler.Filename,
File: filename,
Type: handler.Header.Get("Content-Type"),
Size: int32(handler.Size),
Description: r.FormValue("description"),
}
if params.Name == "" {
params.Name = handler.Filename
}
result, err := h.queries.CreateAttachment(r.Context(), params)
if err != nil {
// Clean up file on error
os.Remove(filepath)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, err := result.LastInsertId()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// If HTMX request, return success message
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<div class="notification is-success">Attachment uploaded successfully</div>`))
return
}
// JSON response for API
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
})
}
func (h *AttachmentHandler) Update(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
return
}
var params db.UpdateAttachmentParams
if r.Header.Get("Content-Type") == "application/json" {
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
} else {
// Handle form data
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
params = db.UpdateAttachmentParams{
Name: r.FormValue("name"),
Description: r.FormValue("description"),
}
}
params.ID = int32(id)
if err := h.queries.UpdateAttachment(r.Context(), params); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
return
}
// Soft delete (archive)
if err := h.queries.DeleteAttachment(r.Context(), int32(id)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -1,778 +0,0 @@
package handlers
import (
"log"
"net/http"
"strconv"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/gorilla/mux"
)
type PageHandler struct {
queries *db.Queries
tmpl *templates.TemplateManager
}
func NewPageHandler(queries *db.Queries, tmpl *templates.TemplateManager) *PageHandler {
return &PageHandler{
queries: queries,
tmpl: tmpl,
}
}
// Home page
func (h *PageHandler) Home(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Title": "Dashboard",
}
if err := h.tmpl.Render(w, "index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Customer pages
func (h *PageHandler) CustomersIndex(w http.ResponseWriter, r *http.Request) {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
page = val
}
}
limit := 20
offset := (page - 1) * limit
customers, err := h.queries.ListCustomers(r.Context(), db.ListCustomersParams{
Limit: int32(limit + 1), // Get one extra to check if there are more
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasMore := len(customers) > limit
if hasMore {
customers = customers[:limit]
}
data := map[string]interface{}{
"Customers": customers,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"HasMore": hasMore,
}
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
if err := h.tmpl.Render(w, "customers/table.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err := h.tmpl.Render(w, "customers/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) CustomersNew(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Customer": db.Customer{},
}
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) CustomersEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
return
}
customer, err := h.queries.GetCustomer(r.Context(), int32(id))
if err != nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Customer": customer,
}
if err := h.tmpl.Render(w, "customers/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) CustomersShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid customer ID", http.StatusBadRequest)
return
}
customer, err := h.queries.GetCustomer(r.Context(), int32(id))
if err != nil {
http.Error(w, "Customer not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Customer": customer,
}
if err := h.tmpl.Render(w, "customers/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) CustomersSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("search")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
page = val
}
}
limit := 20
offset := (page - 1) * limit
var customers []db.Customer
var err error
if query == "" {
customers, err = h.queries.ListCustomers(r.Context(), db.ListCustomersParams{
Limit: int32(limit + 1),
Offset: int32(offset),
})
} else {
customers, err = h.queries.SearchCustomersByName(r.Context(), db.SearchCustomersByNameParams{
CONCAT: query,
CONCAT_2: query,
Limit: int32(limit + 1),
Offset: int32(offset),
})
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasMore := len(customers) > limit
if hasMore {
customers = customers[:limit]
}
data := map[string]interface{}{
"Customers": customers,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"HasMore": hasMore,
}
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.Render(w, "customers/table.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Product page handlers
func (h *PageHandler) ProductsIndex(w http.ResponseWriter, r *http.Request) {
// Similar implementation to CustomersIndex but for products
data := map[string]interface{}{
"Products": []db.Product{}, // Placeholder
}
if err := h.tmpl.Render(w, "products/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) ProductsNew(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Product": db.Product{},
}
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) ProductsShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
product, err := h.queries.GetProduct(r.Context(), int32(id))
if err != nil {
http.Error(w, "Product not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Product": product,
}
if err := h.tmpl.Render(w, "products/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) ProductsEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid product ID", http.StatusBadRequest)
return
}
product, err := h.queries.GetProduct(r.Context(), int32(id))
if err != nil {
http.Error(w, "Product not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Product": product,
}
if err := h.tmpl.Render(w, "products/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) ProductsSearch(w http.ResponseWriter, r *http.Request) {
// Similar to CustomersSearch but for products
data := map[string]interface{}{
"Products": []db.Product{},
}
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.Render(w, "products/table.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Purchase Order page handlers
func (h *PageHandler) PurchaseOrdersIndex(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"PurchaseOrders": []db.PurchaseOrder{},
}
if err := h.tmpl.Render(w, "purchase-orders/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) PurchaseOrdersNew(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"PurchaseOrder": db.PurchaseOrder{},
}
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) PurchaseOrdersShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
return
}
purchaseOrder, err := h.queries.GetPurchaseOrder(r.Context(), int32(id))
if err != nil {
http.Error(w, "Purchase order not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"PurchaseOrder": purchaseOrder,
}
if err := h.tmpl.Render(w, "purchase-orders/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) PurchaseOrdersEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid purchase order ID", http.StatusBadRequest)
return
}
purchaseOrder, err := h.queries.GetPurchaseOrder(r.Context(), int32(id))
if err != nil {
http.Error(w, "Purchase order not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"PurchaseOrder": purchaseOrder,
}
if err := h.tmpl.Render(w, "purchase-orders/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) PurchaseOrdersSearch(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"PurchaseOrders": []db.PurchaseOrder{},
}
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.Render(w, "purchase-orders/table.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Enquiry page handlers
func (h *PageHandler) EnquiriesIndex(w http.ResponseWriter, r *http.Request) {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
page = val
}
}
limit := 150
offset := (page - 1) * limit
var enquiries interface{}
var err error
var hasMore bool
// Check if we want archived enquiries
if r.URL.Query().Get("archived") == "true" {
archivedEnquiries, err := h.queries.ListArchivedEnquiries(r.Context(), db.ListArchivedEnquiriesParams{
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err == nil {
hasMore = len(archivedEnquiries) > limit
if hasMore {
archivedEnquiries = archivedEnquiries[:limit]
}
enquiries = archivedEnquiries
}
} else {
activeEnquiries, err := h.queries.ListEnquiries(r.Context(), db.ListEnquiriesParams{
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err == nil {
hasMore = len(activeEnquiries) > limit
if hasMore {
activeEnquiries = activeEnquiries[:limit]
}
enquiries = activeEnquiries
}
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Get status list for dropdown and CSS classes
statuses, err := h.queries.GetAllStatuses(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Enquiries": enquiries,
"Statuses": statuses,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"HasMore": hasMore,
}
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
if err := h.tmpl.RenderPartial(w, "enquiries/table.html", "enquiry-table", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err := h.tmpl.Render(w, "enquiries/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) EnquiriesNew(w http.ResponseWriter, r *http.Request) {
// Get required form data
statuses, err := h.queries.GetAllStatuses(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
principles, err := h.queries.GetAllPrinciples(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
states, err := h.queries.GetAllStates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
countries, err := h.queries.GetAllCountries(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Enquiry": db.Enquiry{},
"Statuses": statuses,
"Principles": principles,
"States": states,
"Countries": countries,
}
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) EnquiriesShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, "Enquiry not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Enquiry": enquiry,
}
if err := h.tmpl.Render(w, "enquiries/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) EnquiriesEdit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid enquiry ID", http.StatusBadRequest)
return
}
enquiry, err := h.queries.GetEnquiry(r.Context(), int32(id))
if err != nil {
http.Error(w, "Enquiry not found", http.StatusNotFound)
return
}
// Get required form data
statuses, err := h.queries.GetAllStatuses(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
principles, err := h.queries.GetAllPrinciples(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
states, err := h.queries.GetAllStates(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
countries, err := h.queries.GetAllCountries(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Enquiry": enquiry,
"Statuses": statuses,
"Principles": principles,
"States": states,
"Countries": countries,
}
if err := h.tmpl.Render(w, "enquiries/form.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) EnquiriesSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("search")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
page = val
}
}
limit := 150
offset := (page - 1) * limit
var enquiries interface{}
var hasMore bool
if query == "" {
// If no search query, return regular list
regularEnquiries, err := h.queries.ListEnquiries(r.Context(), db.ListEnquiriesParams{
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasMore = len(regularEnquiries) > limit
if hasMore {
regularEnquiries = regularEnquiries[:limit]
}
enquiries = regularEnquiries
} else {
searchResults, err := h.queries.SearchEnquiries(r.Context(), db.SearchEnquiriesParams{
CONCAT: query,
CONCAT_2: query,
CONCAT_3: query,
Limit: int32(limit + 1),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hasMore = len(searchResults) > limit
if hasMore {
searchResults = searchResults[:limit]
}
enquiries = searchResults
}
data := map[string]interface{}{
"Enquiries": enquiries,
"Page": page,
"PrevPage": page - 1,
"NextPage": page + 1,
"HasMore": hasMore,
}
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.RenderPartial(w, "enquiries/table.html", "enquiry-table", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Document page handlers
func (h *PageHandler) DocumentsIndex(w http.ResponseWriter, r *http.Request) {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if val, err := strconv.Atoi(p); err == nil && val > 0 {
page = val
}
}
// Get document type filter
docType := r.URL.Query().Get("type")
limit := 20
offset := (page - 1) * limit
var documents interface{}
var err error
if docType != "" {
documents, err = h.queries.ListDocumentsByType(r.Context(), db.ListDocumentsByTypeParams{
Type: db.DocumentsType(docType),
Limit: int32(limit + 1),
Offset: int32(offset),
})
} else {
documents, err = h.queries.ListDocuments(r.Context(), db.ListDocumentsParams{
Limit: int32(limit + 1),
Offset: int32(offset),
})
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Get users list for display names (if needed)
users, err := h.queries.GetAllUsers(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Documents": documents,
"Users": users,
"Page": page,
"DocType": docType,
}
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
if err := h.tmpl.RenderPartial(w, "documents/table.html", "document-table", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err := h.tmpl.Render(w, "documents/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) DocumentsShow(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid document ID", http.StatusBadRequest)
return
}
document, err := h.queries.GetDocumentWithUser(r.Context(), int32(id))
if err != nil {
log.Printf("Error fetching document %d: %v", id, err)
http.Error(w, "Document not found", http.StatusNotFound)
return
}
data := map[string]interface{}{
"Document": document,
}
if err := h.tmpl.Render(w, "documents/show.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) DocumentsSearch(w http.ResponseWriter, r *http.Request) {
// query := r.URL.Query().Get("search") // TODO: Use when search is implemented
var documents interface{}
var err error
// For now, just return all documents until search is implemented
limit := 20
offset := 0
documents, err = h.queries.ListDocuments(r.Context(), db.ListDocumentsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Documents": documents,
}
w.Header().Set("Content-Type", "text/html")
if err := h.tmpl.RenderPartial(w, "documents/table.html", "document-table", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (h *PageHandler) DocumentsView(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid document ID", http.StatusBadRequest)
return
}
document, err := h.queries.GetDocumentWithUser(r.Context(), int32(id))
if err != nil {
http.Error(w, "Document not found", http.StatusNotFound)
return
}
// Load line items for this document
lineItems, err := h.queries.ListLineItemsByDocument(r.Context(), int32(id))
if err != nil {
log.Printf("Error loading line items for document %d: %v", id, err)
// Don't fail the entire page if line items can't be loaded
lineItems = []db.LineItem{}
}
// Prepare data based on document type
data := map[string]interface{}{
"Document": document,
"DocType": string(document.Type),
"LineItems": lineItems,
}
// Add document type specific data
switch document.Type {
case db.DocumentsTypeQuote:
// For quotes, we might need to load enquiry data
if document.CmcReference != "" {
// The CmcReference for quotes is the enquiry title
data["EnquiryTitle"] = document.CmcReference
}
case db.DocumentsTypeInvoice:
// For invoices, load job and customer data if needed
data["ShowPaymentButton"] = true
case db.DocumentsTypePurchaseOrder:
// For purchase orders, load principle data if needed
case db.DocumentsTypeOrderAck:
// For order acknowledgements, load job data if needed
case db.DocumentsTypePackingList:
// For packing lists, load job data if needed
}
// Render the appropriate template
if err := h.tmpl.Render(w, "documents/view.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

View file

@ -1,154 +0,0 @@
package templates
import (
"fmt"
"html/template"
"io"
"os"
"path/filepath"
"time"
)
type TemplateManager struct {
templates map[string]*template.Template
}
func NewTemplateManager(templatesDir string) (*TemplateManager, error) {
tm := &TemplateManager{
templates: make(map[string]*template.Template),
}
// Define template functions
funcMap := template.FuncMap{
"formatDate": formatDate,
"truncate": truncate,
"currency": formatCurrency,
}
// Load all templates
layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts/*.html"))
if err != nil {
return nil, err
}
partials, err := filepath.Glob(filepath.Join(templatesDir, "partials/*.html"))
if err != nil {
return nil, err
}
// Load page templates
pages := []string{
"customers/index.html",
"customers/show.html",
"customers/form.html",
"customers/table.html",
"products/index.html",
"products/show.html",
"products/form.html",
"products/table.html",
"purchase-orders/index.html",
"purchase-orders/show.html",
"purchase-orders/form.html",
"purchase-orders/table.html",
"enquiries/index.html",
"enquiries/show.html",
"enquiries/form.html",
"enquiries/table.html",
"documents/index.html",
"documents/show.html",
"documents/table.html",
"documents/view.html",
"documents/quote-view.html",
"documents/invoice-view.html",
"documents/purchase-order-view.html",
"documents/orderack-view.html",
"documents/packinglist-view.html",
"index.html",
}
for _, page := range pages {
pagePath := filepath.Join(templatesDir, page)
files := append(layouts, partials...)
files = append(files, pagePath)
// For index pages, also include the corresponding table template
if filepath.Base(page) == "index.html" {
dir := filepath.Dir(page)
tablePath := filepath.Join(templatesDir, dir, "table.html")
// Check if table file exists before adding it
if _, err := os.Stat(tablePath); err == nil {
files = append(files, tablePath)
}
}
// For documents view page, include all document type elements
if page == "documents/view.html" {
docElements := []string{
"documents/quote-view.html",
"documents/invoice-view.html",
"documents/purchase-order-view.html",
"documents/orderack-view.html",
"documents/packinglist-view.html",
}
for _, elem := range docElements {
elemPath := filepath.Join(templatesDir, elem)
if _, err := os.Stat(elemPath); err == nil {
files = append(files, elemPath)
}
}
}
tmpl, err := template.New(filepath.Base(page)).Funcs(funcMap).ParseFiles(files...)
if err != nil {
return nil, err
}
tm.templates[page] = tmpl
}
return tm, nil
}
func (tm *TemplateManager) Render(w io.Writer, name string, data interface{}) error {
tmpl, ok := tm.templates[name]
if !ok {
return template.New("error").Execute(w, "Template not found")
}
return tmpl.ExecuteTemplate(w, "base", data)
}
func (tm *TemplateManager) RenderPartial(w io.Writer, templateFile, templateName string, data interface{}) error {
tmpl, ok := tm.templates[templateFile]
if !ok {
return template.New("error").Execute(w, "Template not found")
}
return tmpl.ExecuteTemplate(w, templateName, data)
}
// Template helper functions
func formatDate(t interface{}) string {
switch v := t.(type) {
case time.Time:
return v.Format("2006-01-02")
case string:
if tm, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
return tm.Format("2006-01-02")
}
return v
default:
return fmt.Sprintf("%v", v)
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func formatCurrency(amount float64) string {
return fmt.Sprintf("$%.2f", amount)
}

Binary file not shown.

View file

@ -1,109 +0,0 @@
/* Custom styles for CMC Sales */
/* Loading spinner */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Smooth transitions */
.htmx-swapping {
opacity: 0;
transition: opacity 200ms ease-out;
}
.htmx-settling {
opacity: 1;
transition: opacity 200ms ease-in;
}
/* Table hover effects */
.table.is-hoverable tbody tr:hover {
background-color: #f5f5f5;
cursor: pointer;
}
/* Form improvements */
.field:not(:last-child) {
margin-bottom: 1.5rem;
}
/* Notification animations */
.notification {
animation: slideIn 300ms ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Breadcrumb improvements */
.breadcrumb {
margin-bottom: 2rem;
}
/* Card hover effects */
.card:hover {
box-shadow: 0 8px 16px rgba(10,10,10,.1);
transition: box-shadow 200ms ease;
}
/* Button loading state */
.button.is-loading::after {
border-color: transparent transparent #fff #fff !important;
}
/* Responsive improvements */
@media screen and (max-width: 768px) {
.level-left,
.level-right {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.level-right {
margin-top: 1rem;
}
}
/* Footer stick to bottom */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.section {
flex: 1;
}
/* Search box improvements */
.control.has-icons-left .input:focus ~ .icon {
color: #3273dc;
}
/* Table action buttons */
.table td .buttons {
margin-bottom: 0;
}
/* Error states */
.input.is-danger:focus {
border-color: #ff3860;
box-shadow: 0 0 0 0.125em rgba(255,56,96,.25);
}

View file

@ -1,106 +0,0 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}CMC Sales{{end}}</title>
<!-- Bulma CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/static/css/style.css">
{{block "head" .}}{{end}}
</head>
<body>
<!-- Navigation -->
<nav class="navbar is-primary" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<strong>CMC Sales</strong>
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMain">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarMain" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/">
<span class="icon"><i class="fas fa-home"></i></span>
<span>Dashboard</span>
</a>
<a class="navbar-item" href="/customers">
<span class="icon"><i class="fas fa-users"></i></span>
<span>Customers</span>
</a>
<a class="navbar-item" href="/products">
<span class="icon"><i class="fas fa-box"></i></span>
<span>Products</span>
</a>
<a class="navbar-item" href="/purchase-orders">
<span class="icon"><i class="fas fa-file-invoice"></i></span>
<span>Purchase Orders</span>
</a>
<a class="navbar-item" href="/enquiries">
<span class="icon"><i class="fas fa-envelope"></i></span>
<span>Enquiries</span>
</a>
</div>
</div>
</nav>
<!-- Main Content -->
<section class="section">
<div class="container">
{{block "content" .}}{{end}}
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong>CMC Sales</strong> &copy; 2024 CMC Technologies
</p>
</div>
</footer>
<!-- Custom JS -->
<script src="/static/js/app.js"></script>
<!-- Navbar toggle script -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
const target = el.dataset.target;
const $target = document.getElementById(target);
el.classList.toggle('is-active');
$target.classList.toggle('is-active');
});
});
});
</script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

25
go/.air.toml Normal file
View file

@ -0,0 +1,25 @@
# Air configuration for Go hot reload
root = "./"
cmd = ["air"]
[build]
cmd = "go build -o server cmd/server/main.go"
bin = "server"
include = ["cmd", "internal", "go.mod", "go.sum"]
exclude = ["bin", "tmp", ".env"]
delay = 1000
log = "stdout"
kill_on_error = true
color = true
[[watch]]
path = "templates"
reload = true
[[watch]]
path = "static"
reload = true
[[watch]]
path = "tmp"
reload = false
mkdir = true

12
go/.env.example Normal file
View file

@ -0,0 +1,12 @@
# Database configuration
DB_HOST=db
DB_PORT=3306
DB_USER=cmc
DB_PASSWORD=xVRQI&cA?7AU=hqJ!%au
DB_NAME=cmc
# Root database password (for dbshell-root)
DB_ROOT_PASSWORD=secureRootPassword
# Server configuration
PORT=8080

70
go/MIGRATIONS.md Normal file
View file

@ -0,0 +1,70 @@
# Database Migrations with Goose
This document explains how to use goose for database migrations in the CMC Sales Go application.
## Setup
1. **Install goose**:
```bash
make install
```
2. **Configure database connection**:
```bash
cp goose.env.example goose.env
# Edit goose.env with your database credentials
```
## Migration Commands
### Run Migrations
```bash
# Run all pending migrations
make migrate
# Check migration status
make migrate-status
```
### Rollback Migrations
```bash
# Rollback the last migration
make migrate-down
```
### Create New Migrations
```bash
# Create a new migration file
make migrate-create name=add_new_feature
```
## Migration Structure
Migrations are stored in `sql/migrations/` and follow this naming convention:
- `001_add_gmail_fields.sql`
- `002_add_new_feature.sql`
Each migration file contains:
```sql
-- +goose Up
-- Your upgrade SQL here
-- +goose Down
-- Your rollback SQL here
```
## Configuration Files
- `goose.env` - Database connection settings (gitignored)
- `goose.env.example` - Template for goose.env
## Current Migrations
1. **001_add_gmail_fields.sql** - Adds Gmail integration fields to emails and email_attachments tables
## Tips
- Always test migrations on a backup database first
- Use `make migrate-status` to check current state
- Migrations are atomic - if one fails, none are applied
- Each migration should be reversible with a corresponding Down section

124
go/Makefile Normal file
View file

@ -0,0 +1,124 @@
.PHONY: help
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: install
install: ## Install dependencies
@echo "Setting up private module configuration..."
go env -w GOPRIVATE=code.springupsoftware.com
go mod download
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/pressly/goose/v3/cmd/goose@latest
.PHONY: sqlc
sqlc: ## Generate Go code from SQL queries
sqlc generate
.PHONY: build
build: sqlc ## Build the application
go build -o bin/server cmd/server/main.go
go build -o bin/vault cmd/vault/main.go
.PHONY: build-server
build-server: sqlc ## Build only the server
go build -o bin/server cmd/server/main.go
.PHONY: build-vault
build-vault: ## Build only the vault command
go build -o bin/vault cmd/vault/main.go
.PHONY: run
run: ## Run the application
go run cmd/server/main.go
.PHONY: run-vault
run-vault: ## Run the vault command
go run cmd/vault/main.go
.PHONY: dev
dev: sqlc ## Run the application with hot reload (requires air)
air
.PHONY: test
test: ## Run tests
go test -v ./...
.PHONY: clean
clean: ## Clean build artifacts
rm -rf bin/
rm -rf internal/cmc/db/*.go
.PHONY: docker-build
docker-build: ## Build Docker image
docker build -t cmc-go:latest -f Dockerfile.go .
.PHONY: docker-run
docker-run: ## Run application in Docker
docker run --rm -p 8080:8080 --network=host cmc-go:latest
.PHONY: dbshell
dbshell: ## Connect to MariaDB database interactively
@echo "Connecting to MariaDB..."
@if [ -z "$$DB_PASSWORD" ]; then \
echo "Reading password from docker-compose environment..."; \
docker compose exec db mariadb -u cmc -p cmc; \
else \
docker compose exec -e MYSQL_PWD="$$DB_PASSWORD" db mariadb -u cmc cmc; \
fi
.PHONY: dbshell-root
dbshell-root: ## Connect to MariaDB as root user
@echo "Connecting to MariaDB as root..."
@if [ -z "$$DB_ROOT_PASSWORD" ]; then \
echo "Please set DB_ROOT_PASSWORD environment variable"; \
exit 1; \
else \
docker compose exec -e MYSQL_PWD="$$DB_ROOT_PASSWORD" db mariadb -u root; \
fi
.PHONY: migrate
migrate: ## Run database migrations
@echo "Running database migrations..."
@if [ -f goose.env ]; then \
export $$(cat goose.env | xargs) && goose up; \
else \
echo "Error: goose.env file not found"; \
exit 1; \
fi
.PHONY: migrate-down
migrate-down: ## Rollback last migration
@echo "Rolling back last migration..."
@if [ -f goose.env ]; then \
export $$(cat goose.env | xargs) && goose down; \
else \
echo "Error: goose.env file not found"; \
exit 1; \
fi
.PHONY: migrate-status
migrate-status: ## Show migration status
@echo "Migration status:"
@if [ -f goose.env ]; then \
export $$(cat goose.env | xargs) && goose status; \
else \
echo "Error: goose.env file not found"; \
exit 1; \
fi
.PHONY: migrate-create
migrate-create: ## Create a new migration file (use: make migrate-create name=add_new_table)
@if [ -z "$(name)" ]; then \
echo "Error: Please provide a migration name. Usage: make migrate-create name=add_new_table"; \
exit 1; \
fi
@echo "Creating new migration: $(name)"
@if [ -f goose.env ]; then \
export $$(cat goose.env | xargs) && goose create $(name) sql; \
else \
echo "Error: goose.env file not found"; \
exit 1; \
fi

299
go/cmd/server/main.go Normal file
View file

@ -0,0 +1,299 @@
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"os"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/email"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/attachments"
quotes "code.springupsoftware.com/cmc/cmc-sales/internal/cmc/handlers/quotes"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
"github.com/go-co-op/gocron"
_ "github.com/go-sql-driver/mysql"
"github.com/gorilla/mux"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
// Database configuration
dbHost := getEnv("DB_HOST", "localhost")
dbPort := getEnv("DB_PORT", "3306")
dbUser := getEnv("DB_USER", "cmc")
dbPass := getEnv("DB_PASSWORD", "xVRQI&cA?7AU=hqJ!%au")
dbName := getEnv("DB_NAME", "cmc")
// Connect to database
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPass, dbHost, dbPort, dbName)
database, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer database.Close()
// Test database connection
if err := database.Ping(); err != nil {
log.Fatal("Failed to ping database:", err)
}
log.Println("Connected to database successfully")
// Create queries instance
queries := db.New(database)
// Initialize template manager
tmpl, err := templates.NewTemplateManager("templates")
if err != nil {
log.Fatal("Failed to initialize templates:", err)
}
// Initialize email service
emailService := email.GetEmailService()
// Load handlers
quoteHandler := quotes.NewQuotesHandler(queries, tmpl, emailService)
attachmentHandler := attachments.NewAttachmentHandler(queries)
// Setup routes
r := mux.NewRouter()
goRouter := r.PathPrefix("/go").Subrouter()
// Static files
goRouter.PathPrefix("/static/").Handler(http.StripPrefix("/go/static/", http.FileServer(http.Dir("static"))))
// PDF files
goRouter.PathPrefix("/pdf/").Handler(http.StripPrefix("/go/pdf/", http.FileServer(http.Dir("webroot/pdf"))))
// Quote routes
goRouter.HandleFunc("/quotes", quoteHandler.QuotesOutstandingView).Methods("GET")
goRouter.HandleFunc("/quotes/send-reminder", quoteHandler.SendManualReminder).Methods("POST")
goRouter.HandleFunc("/quotes/disable-reminders", quoteHandler.DisableReminders).Methods("POST")
goRouter.HandleFunc("/quotes/enable-reminders", quoteHandler.EnableReminders).Methods("POST")
// Attachment routes
goRouter.HandleFunc("/attachments/upload", attachmentHandler.Create).Methods("POST")
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
goRouter.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
// The following routes are currently disabled:
/*
// API routes
api := r.PathPrefix("/api/v1").Subrouter()
api.HandleFunc("/customers", customerHandler.List).Methods("GET")
api.HandleFunc("/customers", customerHandler.Create).Methods("POST")
api.HandleFunc("/customers/{id}", customerHandler.Get).Methods("GET")
api.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
api.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
api.HandleFunc("/customers/search", customerHandler.Search).Methods("GET")
// Product routes
api.HandleFunc("/products", productHandler.List).Methods("GET")
api.HandleFunc("/products", productHandler.Create).Methods("POST")
api.HandleFunc("/products/{id}", productHandler.Get).Methods("GET")
api.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
api.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
api.HandleFunc("/products/search", productHandler.Search).Methods("GET")
// Purchase Order routes
api.HandleFunc("/purchase-orders", purchaseOrderHandler.List).Methods("GET")
api.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Get).Methods("GET")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
api.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
api.HandleFunc("/purchase-orders/search", purchaseOrderHandler.Search).Methods("GET")
// Enquiry routes
api.HandleFunc("/enquiries", enquiryHandler.List).Methods("GET")
api.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Get).Methods("GET")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
api.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
api.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
api.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
api.HandleFunc("/enquiries/search", enquiryHandler.Search).Methods("GET")
// Document routes
api.HandleFunc("/documents", documentHandler.List).Methods("GET")
api.HandleFunc("/documents", documentHandler.Create).Methods("POST")
api.HandleFunc("/documents/{id}", documentHandler.Get).Methods("GET")
api.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
api.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
api.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
api.HandleFunc("/documents/search", documentHandler.Search).Methods("GET")
// Address routes
api.HandleFunc("/addresses", addressHandler.List).Methods("GET")
api.HandleFunc("/addresses", addressHandler.Create).Methods("POST")
api.HandleFunc("/addresses/{id}", addressHandler.Get).Methods("GET")
api.HandleFunc("/addresses/{id}", addressHandler.Update).Methods("PUT")
api.HandleFunc("/addresses/{id}", addressHandler.Delete).Methods("DELETE")
api.HandleFunc("/addresses/customer/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
// Attachment routes
api.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
api.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
api.HandleFunc("/attachments", attachmentHandler.Create).Methods("POST")
api.HandleFunc("/attachments/{id}", attachmentHandler.Get).Methods("GET")
api.HandleFunc("/attachments/{id}", attachmentHandler.Update).Methods("PUT")
api.HandleFunc("/attachments/{id}", attachmentHandler.Delete).Methods("DELETE")
// Country routes
api.HandleFunc("/countries", countryHandler.List).Methods("GET")
api.HandleFunc("/countries", countryHandler.Create).Methods("POST")
api.HandleFunc("/countries/{id}", countryHandler.Get).Methods("GET")
api.HandleFunc("/countries/{id}", countryHandler.Update).Methods("PUT")
api.HandleFunc("/countries/{id}", countryHandler.Delete).Methods("DELETE")
api.HandleFunc("/countries/complete", countryHandler.CompleteCountry).Methods("GET")
// Status routes
api.HandleFunc("/statuses", statusHandler.List).Methods("GET")
api.HandleFunc("/statuses", statusHandler.Create).Methods("POST")
api.HandleFunc("/statuses/{id}", statusHandler.Get).Methods("GET")
api.HandleFunc("/statuses/{id}", statusHandler.Update).Methods("PUT")
api.HandleFunc("/statuses/{id}", statusHandler.Delete).Methods("DELETE")
api.HandleFunc("/statuses/json/{selectedId}", statusHandler.JsonList).Methods("GET")
// Line Item routes
api.HandleFunc("/line-items", lineItemHandler.List).Methods("GET")
api.HandleFunc("/line-items", lineItemHandler.Create).Methods("POST")
api.HandleFunc("/line-items/{id}", lineItemHandler.Get).Methods("GET")
api.HandleFunc("/line-items/{id}", lineItemHandler.Update).Methods("PUT")
api.HandleFunc("/line-items/{id}", lineItemHandler.Delete).Methods("DELETE")
api.HandleFunc("/line-items/document/{documentID}/table", lineItemHandler.GetTable).Methods("GET")
// Health check
api.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}).Methods("GET")
// Recent activity endpoint
r.HandleFunc("/api/recent-activity", documentHandler.GetRecentActivity).Methods("GET")
// Page routes
r.HandleFunc("/", pageHandler.Home).Methods("GET")
// Customer pages
r.HandleFunc("/customers", pageHandler.CustomersIndex).Methods("GET")
r.HandleFunc("/customers/new", pageHandler.CustomersNew).Methods("GET")
r.HandleFunc("/customers/search", pageHandler.CustomersSearch).Methods("GET")
r.HandleFunc("/customers/{id}", pageHandler.CustomersShow).Methods("GET")
r.HandleFunc("/customers/{id}/edit", pageHandler.CustomersEdit).Methods("GET")
// Product pages
r.HandleFunc("/products", pageHandler.ProductsIndex).Methods("GET")
r.HandleFunc("/products/new", pageHandler.ProductsNew).Methods("GET")
r.HandleFunc("/products/search", pageHandler.ProductsSearch).Methods("GET")
r.HandleFunc("/products/{id}", pageHandler.ProductsShow).Methods("GET")
r.HandleFunc("/products/{id}/edit", pageHandler.ProductsEdit).Methods("GET")
// Purchase Order pages
r.HandleFunc("/purchase-orders", pageHandler.PurchaseOrdersIndex).Methods("GET")
r.HandleFunc("/purchase-orders/new", pageHandler.PurchaseOrdersNew).Methods("GET")
r.HandleFunc("/purchase-orders/search", pageHandler.PurchaseOrdersSearch).Methods("GET")
r.HandleFunc("/purchase-orders/{id}", pageHandler.PurchaseOrdersShow).Methods("GET")
r.HandleFunc("/purchase-orders/{id}/edit", pageHandler.PurchaseOrdersEdit).Methods("GET")
// Enquiry pages
r.HandleFunc("/enquiries", pageHandler.EnquiriesIndex).Methods("GET")
r.HandleFunc("/enquiries/new", pageHandler.EnquiriesNew).Methods("GET")
r.HandleFunc("/enquiries/search", pageHandler.EnquiriesSearch).Methods("GET")
r.HandleFunc("/enquiries/{id}", pageHandler.EnquiriesShow).Methods("GET")
r.HandleFunc("/enquiries/{id}/edit", pageHandler.EnquiriesEdit).Methods("GET")
// Document pages
r.HandleFunc("/documents", pageHandler.DocumentsIndex).Methods("GET")
r.HandleFunc("/documents/search", pageHandler.DocumentsSearch).Methods("GET")
r.HandleFunc("/documents/view/{id}", pageHandler.DocumentsView).Methods("GET")
r.HandleFunc("/documents/{id}", pageHandler.DocumentsShow).Methods("GET")
r.HandleFunc("/documents/pdf/{id}", documentHandler.GeneratePDF).Methods("GET")
// Address routes (matching CakePHP)
r.HandleFunc("/addresses", addressHandler.List).Methods("GET")
r.HandleFunc("/addresses/view/{id}", addressHandler.Get).Methods("GET")
r.HandleFunc("/addresses/add/{customerid}", addressHandler.Create).Methods("GET", "POST")
r.HandleFunc("/addresses/add_another/{increment}", addressHandler.AddAnother).Methods("GET")
r.HandleFunc("/addresses/remove_another/{increment}", addressHandler.RemoveAnother).Methods("GET")
r.HandleFunc("/addresses/edit/{id}", addressHandler.Update).Methods("GET", "POST")
r.HandleFunc("/addresses/customer_addresses/{customerID}", addressHandler.CustomerAddresses).Methods("GET")
// Attachment routes (matching CakePHP)
r.HandleFunc("/attachments", attachmentHandler.List).Methods("GET")
r.HandleFunc("/attachments/view/{id}", attachmentHandler.Get).Methods("GET")
r.HandleFunc("/attachments/archived", attachmentHandler.Archived).Methods("GET")
r.HandleFunc("/attachments/add", attachmentHandler.Create).Methods("GET", "POST")
r.HandleFunc("/attachments/edit/{id}", attachmentHandler.Update).Methods("GET", "POST")
r.HandleFunc("/attachments/delete/{id}", attachmentHandler.Delete).Methods("POST")
// Country routes (matching CakePHP)
r.HandleFunc("/countries", countryHandler.List).Methods("GET")
r.HandleFunc("/countries/view/{id}", countryHandler.Get).Methods("GET")
r.HandleFunc("/countries/add", countryHandler.Create).Methods("GET", "POST")
r.HandleFunc("/countries/edit/{id}", countryHandler.Update).Methods("GET", "POST")
r.HandleFunc("/countries/delete/{id}", countryHandler.Delete).Methods("POST")
r.HandleFunc("/countries/complete_country", countryHandler.CompleteCountry).Methods("GET")
// Status routes (matching CakePHP)
r.HandleFunc("/statuses", statusHandler.List).Methods("GET")
r.HandleFunc("/statuses/view/{id}", statusHandler.Get).Methods("GET")
r.HandleFunc("/statuses/add", statusHandler.Create).Methods("GET", "POST")
r.HandleFunc("/statuses/edit/{id}", statusHandler.Update).Methods("GET", "POST")
r.HandleFunc("/statuses/delete/{id}", statusHandler.Delete).Methods("POST")
r.HandleFunc("/statuses/json_list/{selectedId}", statusHandler.JsonList).Methods("GET")
// Line Item routes (matching CakePHP)
r.HandleFunc("/line_items/ajax_add", lineItemHandler.AjaxAdd).Methods("POST")
r.HandleFunc("/line_items/ajax_edit", lineItemHandler.AjaxEdit).Methods("POST")
r.HandleFunc("/line_items/ajax_delete/{id}", lineItemHandler.AjaxDelete).Methods("POST")
r.HandleFunc("/line_items/get_table/{documentID}", lineItemHandler.GetTable).Methods("GET")
r.HandleFunc("/line_items/edit/{id}", lineItemHandler.Update).Methods("GET", "POST")
r.HandleFunc("/line_items/add/{documentID}", lineItemHandler.Create).Methods("GET", "POST")
// HTMX endpoints
r.HandleFunc("/customers", customerHandler.Create).Methods("POST")
r.HandleFunc("/customers/{id}", customerHandler.Update).Methods("PUT")
r.HandleFunc("/customers/{id}", customerHandler.Delete).Methods("DELETE")
r.HandleFunc("/products", productHandler.Create).Methods("POST")
r.HandleFunc("/products/{id}", productHandler.Update).Methods("PUT")
r.HandleFunc("/products/{id}", productHandler.Delete).Methods("DELETE")
r.HandleFunc("/purchase-orders", purchaseOrderHandler.Create).Methods("POST")
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Update).Methods("PUT")
r.HandleFunc("/purchase-orders/{id}", purchaseOrderHandler.Delete).Methods("DELETE")
r.HandleFunc("/enquiries", enquiryHandler.Create).Methods("POST")
r.HandleFunc("/enquiries/{id}", enquiryHandler.Update).Methods("PUT")
r.HandleFunc("/enquiries/{id}", enquiryHandler.Delete).Methods("DELETE")
r.HandleFunc("/enquiries/{id}/undelete", enquiryHandler.Undelete).Methods("PUT")
r.HandleFunc("/enquiries/{id}/status", enquiryHandler.UpdateStatus).Methods("PUT")
r.HandleFunc("/enquiries/{id}/mark-submitted", enquiryHandler.MarkSubmitted).Methods("PUT")
r.HandleFunc("/documents", documentHandler.Create).Methods("POST")
r.HandleFunc("/documents/{id}", documentHandler.Update).Methods("PUT")
r.HandleFunc("/documents/{id}/archive", documentHandler.Archive).Methods("PUT")
r.HandleFunc("/documents/{id}/unarchive", documentHandler.Unarchive).Methods("PUT")
*/
// Catch-all for everything else
r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404 page not found"))
})
/* Cron Jobs */
go func() {
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
log.Printf("Failed to load Sydney timezone: %v", err)
loc = time.UTC // fallback to UTC
}
s := gocron.NewScheduler(loc)
s.Every(1).Day().At("08:00").Do(func() {
// Checks quotes for reminders and expiry notices
quoteHandler.DailyQuoteExpirationCheck()
})
s.Every(1).Minute().Do(func() {
// Checks quotes for reminders and expiry notices
quoteHandler.DailyQuoteExpirationCheck()
})
s.StartAsync()
}()
// Start server
port := getEnv("PORT", "8080")
log.Printf("Starting server on port %s", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatal("Failed to start server:", err)
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

142
go/cmd/vault/README.md Normal file
View file

@ -0,0 +1,142 @@
# Vault Email Processor - Smart Proxy
This is a Go rewrite of the PHP vault.php script that processes emails for the CMC Sales system. It now supports three modes: local file processing, Gmail indexing, and HTTP streaming proxy.
## Key Features
1. **Gmail Integration**: Index Gmail emails without downloading
2. **Smart Proxy**: Stream email content on-demand without storing to disk
3. **No ripmime dependency**: Uses the enmime Go library for MIME parsing
4. **Better error handling**: Proper error handling and database transactions
5. **Type safety**: Strongly typed Go structures
6. **Modern email parsing**: Uses enmime for robust email parsing
## Operating Modes
### 1. Local Mode (Original functionality)
Processes emails from local filesystem directories.
```bash
./vault --mode=local \
--emaildir=/var/www/emails \
--vaultdir=/var/www/vaultmsgs/new \
--processeddir=/var/www/vaultmsgs/cur \
--dbhost=127.0.0.1 \
--dbuser=cmc \
--dbpass="xVRQI&cA?7AU=hqJ!%au" \
--dbname=cmc
```
**Note:** The `emaildir` should be set to `/var/www/emails` to match the legacy directory structure expected by the CakePHP application.
### 2. Gmail Index Mode
Indexes Gmail emails without downloading content. Creates database references only.
```bash
./vault --mode=index \
--gmail-query="is:unread" \
--credentials=credentials.json \
--token=token.json \
--dbhost=127.0.0.1 \
--dbuser=cmc \
--dbpass="xVRQI&cA?7AU=hqJ!%au" \
--dbname=cmc
```
### 3. HTTP Server Mode
Runs an HTTP server that streams Gmail content on-demand.
```bash
./vault --mode=serve \
--port=8080 \
--credentials=credentials.json \
--token=token.json \
--dbhost=127.0.0.1 \
--dbuser=cmc \
--dbpass="xVRQI&cA?7AU=hqJ!%au" \
--dbname=cmc
```
## Gmail Setup
1. Enable Gmail API in Google Cloud Console
2. Create OAuth 2.0 credentials
3. Download credentials as `credentials.json`
4. Run vault in any Gmail mode - it will prompt for authorization
5. Token will be saved as `token.json` for future use
## API Endpoints (Server Mode)
- `GET /api/emails` - List indexed emails (metadata only)
- `GET /api/emails/:id` - Get email metadata
- `GET /api/emails/:id/content` - Stream email HTML/text from Gmail
- `GET /api/emails/:id/attachments` - List attachment metadata
- `GET /api/emails/:id/attachments/:attachmentId` - Stream attachment from Gmail
- `GET /api/emails/:id/raw` - Stream raw email (for email clients)
## Database Schema Changes
Required migrations for Gmail support:
```sql
ALTER TABLE emails
ADD COLUMN gmail_message_id VARCHAR(255) UNIQUE,
ADD COLUMN gmail_thread_id VARCHAR(255),
ADD COLUMN is_downloaded BOOLEAN DEFAULT FALSE,
ADD COLUMN raw_headers TEXT;
CREATE INDEX idx_gmail_message_id ON emails(gmail_message_id);
ALTER TABLE email_attachments
ADD COLUMN gmail_attachment_id VARCHAR(255),
ADD COLUMN gmail_message_id VARCHAR(255);
```
## Architecture
### Smart Proxy Benefits
- **No Disk Storage**: Emails/attachments streamed directly from Gmail
- **Low Storage Footprint**: Only metadata stored in database
- **Fresh Content**: Always serves latest version from Gmail
- **Scalable**: No file management overhead
- **On-Demand**: Content fetched only when requested
### Processing Flow
1. **Index Mode**: Scans Gmail, stores metadata, creates associations
2. **Server Mode**: Receives HTTP requests, fetches from Gmail, streams to client
3. **Local Mode**: Original file-based processing (backwards compatible)
## Build
```bash
go build -o vault cmd/vault/main.go
```
## Dependencies
- github.com/jhillyerd/enmime - MIME email parsing
- github.com/google/uuid - UUID generation
- github.com/go-sql-driver/mysql - MySQL driver
- github.com/gorilla/mux - HTTP router
- golang.org/x/oauth2 - OAuth2 support
- google.golang.org/api/gmail/v1 - Gmail API client
## Database Tables Used
- emails - Main email records with Gmail metadata
- email_recipients - To/CC recipients
- email_attachments - Attachment metadata (no file storage)
- emails_enquiries - Email to enquiry associations
- emails_invoices - Email to invoice associations
- emails_purchase_orders - Email to PO associations
- emails_jobs - Email to job associations
- users - System users
- enquiries, invoices, purchase_orders, jobs - For identifier matching
## Gmail Query Examples
- `is:unread` - Unread emails
- `newer_than:1d` - Emails from last 24 hours
- `from:customer@example.com` - From specific sender
- `subject:invoice` - Subject contains "invoice"
- `has:attachment` - Emails with attachments

1300
go/cmd/vault/main.go Normal file

File diff suppressed because it is too large Load diff

22
go/go.mod Normal file
View file

@ -0,0 +1,22 @@
module code.springupsoftware.com/cmc/cmc-sales
go 1.23.0
toolchain go1.24.3
require (
github.com/go-sql-driver/mysql v1.7.1
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/jhillyerd/enmime v1.3.0
github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf v1.16.2
golang.org/x/text v0.27.0
)
require (
github.com/go-co-op/gocron v1.37.0 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
)

73
go/go.sum Normal file
View file

@ -0,0 +1,73 @@
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

3
go/goose.env.example Normal file
View file

@ -0,0 +1,3 @@
GOOSE_DRIVER=mysql
GOOSE_DBSTRING=username:password@tcp(localhost:3306)/database?parseTime=true
GOOSE_MIGRATION_DIR=sql/migrations

View file

@ -0,0 +1,109 @@
package auth
import (
"context"
"net/http"
"strings"
)
// ContextKey is a type for context keys to avoid collisions
type ContextKey string
const (
// ContextKeyUsername is the context key for storing the authenticated username
ContextKeyUsername ContextKey = "username"
// ContextKeyAuthUser is the context key for storing the raw auth username
ContextKeyAuthUser ContextKey = "auth_user"
// ContextKeyAuthPass is the context key for storing the raw auth password
ContextKeyAuthPass ContextKey = "auth_pass"
)
// Credentials holds authentication credentials
type Credentials struct {
Username string
Password string
}
// GetCredentials extracts authentication credentials from the request
// This is the single point where auth mechanism is defined
func GetCredentials(r *http.Request) (*Credentials, bool) {
username, password, ok := r.BasicAuth()
if !ok || username == "" {
return nil, false
}
return &Credentials{
Username: username,
Password: password,
}, true
}
// GetUsername extracts and formats the username for display
func GetUsername(r *http.Request) string {
creds, ok := GetCredentials(r)
if !ok {
return "Guest"
}
// Capitalize the username for display
return strings.Title(creds.Username)
}
// GetUsernameFromContext retrieves the username from the request context
func GetUsernameFromContext(ctx context.Context) string {
if username, ok := ctx.Value(ContextKeyUsername).(string); ok {
return username
}
return "Guest"
}
// GetCredentialsFromContext retrieves credentials from the request context
func GetCredentialsFromContext(ctx context.Context) (*Credentials, bool) {
username, okUser := ctx.Value(ContextKeyAuthUser).(string)
password, okPass := ctx.Value(ContextKeyAuthPass).(string)
if !okUser || !okPass {
return nil, false
}
return &Credentials{
Username: username,
Password: password,
}, true
}
// AddAuthToRequest adds authentication credentials to an HTTP request
// This should be used when making authenticated requests to internal services
func AddAuthToRequest(req *http.Request, creds *Credentials) {
if creds != nil {
req.SetBasicAuth(creds.Username, creds.Password)
}
}
// NewAuthenticatedRequest creates a new HTTP request with authentication from the source request
// This is a convenience method that extracts auth from sourceReq and applies it to the new request
func NewAuthenticatedRequest(method, url string, sourceReq *http.Request) (*http.Request, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
// Copy authentication from source request
if creds, ok := GetCredentials(sourceReq); ok {
AddAuthToRequest(req, creds)
}
return req, nil
}
// Middleware adds authentication information to the request context
// This allows handlers to access auth info without parsing headers repeatedly
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
creds, ok := GetCredentials(r)
if ok {
ctx := r.Context()
ctx = context.WithValue(ctx, ContextKeyUsername, strings.Title(creds.Username))
ctx = context.WithValue(ctx, ContextKeyAuthUser, creds.Username)
ctx = context.WithValue(ctx, ContextKeyAuthPass, creds.Password)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,409 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"database/sql"
"database/sql/driver"
"fmt"
"time"
)
type DocumentsType string
const (
DocumentsTypeQuote DocumentsType = "quote"
DocumentsTypeInvoice DocumentsType = "invoice"
DocumentsTypePurchaseOrder DocumentsType = "purchaseOrder"
DocumentsTypeOrderAck DocumentsType = "orderAck"
DocumentsTypePackingList DocumentsType = "packingList"
)
func (e *DocumentsType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = DocumentsType(s)
case string:
*e = DocumentsType(s)
default:
return fmt.Errorf("unsupported scan type for DocumentsType: %T", src)
}
return nil
}
type NullDocumentsType struct {
DocumentsType DocumentsType `json:"documents_type"`
Valid bool `json:"valid"` // Valid is true if DocumentsType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullDocumentsType) Scan(value interface{}) error {
if value == nil {
ns.DocumentsType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.DocumentsType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullDocumentsType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.DocumentsType), nil
}
type UsersAccessLevel string
const (
UsersAccessLevelAdmin UsersAccessLevel = "admin"
UsersAccessLevelManager UsersAccessLevel = "manager"
UsersAccessLevelUser UsersAccessLevel = "user"
UsersAccessLevelNone UsersAccessLevel = "none"
)
func (e *UsersAccessLevel) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = UsersAccessLevel(s)
case string:
*e = UsersAccessLevel(s)
default:
return fmt.Errorf("unsupported scan type for UsersAccessLevel: %T", src)
}
return nil
}
type NullUsersAccessLevel struct {
UsersAccessLevel UsersAccessLevel `json:"users_access_level"`
Valid bool `json:"valid"` // Valid is true if UsersAccessLevel is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullUsersAccessLevel) Scan(value interface{}) error {
if value == nil {
ns.UsersAccessLevel, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.UsersAccessLevel.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullUsersAccessLevel) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.UsersAccessLevel), nil
}
type UsersType string
const (
UsersTypePrinciple UsersType = "principle"
UsersTypeContact UsersType = "contact"
UsersTypeUser UsersType = "user"
)
func (e *UsersType) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = UsersType(s)
case string:
*e = UsersType(s)
default:
return fmt.Errorf("unsupported scan type for UsersType: %T", src)
}
return nil
}
type NullUsersType struct {
UsersType UsersType `json:"users_type"`
Valid bool `json:"valid"` // Valid is true if UsersType is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullUsersType) Scan(value interface{}) error {
if value == nil {
ns.UsersType, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.UsersType.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullUsersType) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.UsersType), nil
}
type Address struct {
ID int32 `json:"id"`
// Descriptive Name for this address
Name string `json:"name"`
// street or unit number and street name
Address string `json:"address"`
// Suburb / City
City string `json:"city"`
// State foreign Key
StateID int32 `json:"state_id"`
// Country foreign Key
CountryID int32 `json:"country_id"`
// Customer foreign key
CustomerID int32 `json:"customer_id"`
// either bill / ship / both
Type string `json:"type"`
Postcode string `json:"postcode"`
}
type Attachment struct {
ID int32 `json:"id"`
PrincipleID int32 `json:"principle_id"`
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
Name string `json:"name"`
Filename string `json:"filename"`
File string `json:"file"`
Type string `json:"type"`
Size int32 `json:"size"`
Description string `json:"description"`
Archived bool `json:"archived"`
}
type Box struct {
ID int32 `json:"id"`
ShipmentID int32 `json:"shipment_id"`
Length string `json:"length"`
Width string `json:"width"`
Height string `json:"height"`
Weight string `json:"weight"`
}
type Country struct {
ID int32 `json:"id"`
Name string `json:"name"`
}
type Customer struct {
ID int32 `json:"id"`
// Company Name
Name string `json:"name"`
TradingName string `json:"trading_name"`
Abn sql.NullString `json:"abn"`
Created time.Time `json:"created"`
Notes string `json:"notes"`
DiscountPricingPolicies string `json:"discount_pricing_policies"`
PaymentTerms string `json:"payment_terms"`
CustomerCategoryID int32 `json:"customer_category_id"`
Url string `json:"url"`
CountryID int32 `json:"country_id"`
}
type Document struct {
ID int32 `json:"id"`
Type DocumentsType `json:"type"`
Created time.Time `json:"created"`
UserID int32 `json:"user_id"`
DocPageCount int32 `json:"doc_page_count"`
// Either the Enquiry number, Invoice no, order ack. Convenient place to store this to save on queries
CmcReference string `json:"cmc_reference"`
PdfFilename string `json:"pdf_filename"`
PdfCreatedAt time.Time `json:"pdf_created_at"`
PdfCreatedByUserID int32 `json:"pdf_created_by_user_id"`
ShippingDetails sql.NullString `json:"shipping_details"`
Revision int32 `json:"revision"`
BillTo sql.NullString `json:"bill_to"`
ShipTo sql.NullString `json:"ship_to"`
EmailSentAt time.Time `json:"email_sent_at"`
EmailSentByUserID int32 `json:"email_sent_by_user_id"`
}
type Enquiry struct {
ID int32 `json:"id"`
Created time.Time `json:"created"`
Submitted sql.NullTime `json:"submitted"`
// enquirynumber
Title string `json:"title"`
UserID int32 `json:"user_id"`
CustomerID int32 `json:"customer_id"`
ContactID int32 `json:"contact_id"`
ContactUserID int32 `json:"contact_user_id"`
StateID int32 `json:"state_id"`
CountryID int32 `json:"country_id"`
PrincipleID int32 `json:"principle_id"`
StatusID int32 `json:"status_id"`
Comments string `json:"comments"`
// Numeric Principle Code
PrincipleCode int32 `json:"principle_code"`
// GST applicable on this enquiry
Gst bool `json:"gst"`
BillingAddressID sql.NullInt32 `json:"billing_address_id"`
ShippingAddressID sql.NullInt32 `json:"shipping_address_id"`
// has the enquired been posted
Posted bool `json:"posted"`
EmailCount int32 `json:"email_count"`
InvoiceCount int32 `json:"invoice_count"`
JobCount int32 `json:"job_count"`
QuoteCount int32 `json:"quote_count"`
Archived int8 `json:"archived"`
}
type Invoice struct {
ID int32 `json:"id"`
// CMC Invoice Number String
Title string `json:"title"`
CustomerID int32 `json:"customer_id"`
}
type LineItem struct {
ID int32 `json:"id"`
ItemNumber string `json:"item_number"`
Option bool `json:"option"`
Quantity string `json:"quantity"`
Title string `json:"title"`
Description string `json:"description"`
DocumentID int32 `json:"document_id"`
ProductID sql.NullInt32 `json:"product_id"`
HasTextPrices bool `json:"has_text_prices"`
HasPrice int8 `json:"has_price"`
UnitPriceString sql.NullString `json:"unit_price_string"`
GrossPriceString sql.NullString `json:"gross_price_string"`
CostingID sql.NullInt32 `json:"costing_id"`
// Either fill this in or have a costing_id associated with this record
GrossUnitPrice sql.NullString `json:"gross_unit_price"`
NetUnitPrice sql.NullString `json:"net_unit_price"`
DiscountPercent sql.NullString `json:"discount_percent"`
DiscountAmountUnit sql.NullString `json:"discount_amount_unit"`
DiscountAmountTotal sql.NullString `json:"discount_amount_total"`
GrossPrice sql.NullString `json:"gross_price"`
NetPrice sql.NullString `json:"net_price"`
}
type Principle struct {
ID int32 `json:"id"`
Name string `json:"name"`
ShortName sql.NullString `json:"short_name"`
Code int32 `json:"code"`
}
type Product struct {
ID int32 `json:"id"`
// Principle FK
PrincipleID int32 `json:"principle_id"`
ProductCategoryID int32 `json:"product_category_id"`
// This must match the Title in the Excel Costing File
Title string `json:"title"`
Description string `json:"description"`
// Part or model number principle uses to identify this product
ModelNumber sql.NullString `json:"model_number"`
// %1% - first item, %2% , second item etc
ModelNumberFormat sql.NullString `json:"model_number_format"`
// Any notes about this product. Note displayed on quotes
Notes sql.NullString `json:"notes"`
// Stock or Ident
Stock bool `json:"stock"`
ItemCode string `json:"item_code"`
ItemDescription string `json:"item_description"`
}
type PurchaseOrder struct {
ID int32 `json:"id"`
IssueDate time.Time `json:"issue_date"`
DispatchDate time.Time `json:"dispatch_date"`
DateArrived time.Time `json:"date_arrived"`
// CMC PONumber
Title string `json:"title"`
PrincipleID int32 `json:"principle_id"`
PrincipleReference string `json:"principle_reference"`
DocumentID int32 `json:"document_id"`
CurrencyID sql.NullInt32 `json:"currency_id"`
OrderedFrom string `json:"ordered_from"`
Description string `json:"description"`
DispatchBy string `json:"dispatch_by"`
DeliverTo string `json:"deliver_to"`
ShippingInstructions string `json:"shipping_instructions"`
JobsText string `json:"jobs_text"`
FreightForwarderText string `json:"freight_forwarder_text"`
ParentPurchaseOrderID int32 `json:"parent_purchase_order_id"`
}
type Quote struct {
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
ID int32 `json:"id"`
EnquiryID int32 `json:"enquiry_id"`
CurrencyID int32 `json:"currency_id"`
// limited at 5 digits. Really, you're not going to have more revisions of a single quote than that
Revision int32 `json:"revision"`
// estimated delivery time for quote
DeliveryTime string `json:"delivery_time"`
DeliveryTimeFrame string `json:"delivery_time_frame"`
PaymentTerms string `json:"payment_terms"`
DaysValid int32 `json:"days_valid"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
DeliveryPoint string `json:"delivery_point"`
ExchangeRate string `json:"exchange_rate"`
CustomsDuty string `json:"customs_duty"`
DocumentID int32 `json:"document_id"`
CommercialComments sql.NullString `json:"commercial_comments"`
}
type QuoteReminder struct {
ID int32 `json:"id"`
QuoteID int32 `json:"quote_id"`
// 1=1st, 2=2nd, 3=3rd reminder
ReminderType int32 `json:"reminder_type"`
DateSent time.Time `json:"date_sent"`
// User who manually (re)sent the reminder
Username sql.NullString `json:"username"`
}
type State struct {
ID int32 `json:"id"`
Name string `json:"name"`
Shortform sql.NullString `json:"shortform"`
Enqform sql.NullString `json:"enqform"`
}
type Status struct {
ID int32 `json:"id"`
Name string `json:"name"`
Color sql.NullString `json:"color"`
}
type User struct {
ID int32 `json:"id"`
PrincipleID int32 `json:"principle_id"`
CustomerID int32 `json:"customer_id"`
Type UsersType `json:"type"`
AccessLevel UsersAccessLevel `json:"access_level"`
Username string `json:"username"`
Password string `json:"password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
JobTitle string `json:"job_title"`
Phone string `json:"phone"`
Mobile string `json:"mobile"`
Fax string `json:"fax"`
PhoneExtension string `json:"phone_extension"`
DirectPhone string `json:"direct_phone"`
Notes string `json:"notes"`
// Added by Vault. May or may not be a real person.
ByVault bool `json:"by_vault"`
// Disregard emails from this address in future.
Blacklisted bool `json:"blacklisted"`
Enabled bool `json:"enabled"`
Archived sql.NullBool `json:"archived"`
PrimaryContact bool `json:"primary_contact"`
}

View file

@ -0,0 +1,136 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"database/sql"
)
type Querier interface {
ArchiveEnquiry(ctx context.Context, id int32) error
ArchiveUser(ctx context.Context, id int32) error
CountDocuments(ctx context.Context) (int64, error)
CountDocumentsByType(ctx context.Context, type_ DocumentsType) (int64, error)
CountEnquiries(ctx context.Context) (int64, error)
CountEnquiriesByPrinciple(ctx context.Context, principleCode int32) (int64, error)
CountEnquiriesByPrincipleAndState(ctx context.Context, arg CountEnquiriesByPrincipleAndStateParams) (int64, error)
CountEnquiriesByStatus(ctx context.Context, statusID int32) (int64, error)
CreateAddress(ctx context.Context, arg CreateAddressParams) (sql.Result, error)
CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (sql.Result, error)
CreateBox(ctx context.Context, arg CreateBoxParams) (sql.Result, error)
CreateCountry(ctx context.Context, name string) (sql.Result, error)
CreateCustomer(ctx context.Context, arg CreateCustomerParams) (sql.Result, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (sql.Result, error)
CreateEnquiry(ctx context.Context, arg CreateEnquiryParams) (sql.Result, error)
CreateLineItem(ctx context.Context, arg CreateLineItemParams) (sql.Result, error)
CreatePrinciple(ctx context.Context, arg CreatePrincipleParams) (sql.Result, error)
CreateProduct(ctx context.Context, arg CreateProductParams) (sql.Result, error)
CreatePurchaseOrder(ctx context.Context, arg CreatePurchaseOrderParams) (sql.Result, error)
CreateState(ctx context.Context, arg CreateStateParams) (sql.Result, error)
CreateStatus(ctx context.Context, arg CreateStatusParams) (sql.Result, error)
CreateUser(ctx context.Context, arg CreateUserParams) (sql.Result, error)
DeleteAddress(ctx context.Context, id int32) error
DeleteAttachment(ctx context.Context, id int32) error
DeleteBox(ctx context.Context, id int32) error
DeleteCountry(ctx context.Context, id int32) error
DeleteCustomer(ctx context.Context, id int32) error
DeleteDocument(ctx context.Context, id int32) error
DeleteLineItem(ctx context.Context, id int32) error
DeleteProduct(ctx context.Context, id int32) error
DeletePurchaseOrder(ctx context.Context, id int32) error
DeleteState(ctx context.Context, id int32) error
DeleteStatus(ctx context.Context, id int32) error
DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error)
EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error)
GetAddress(ctx context.Context, id int32) (Address, error)
GetAllCountries(ctx context.Context) ([]Country, error)
GetAllPrinciples(ctx context.Context) ([]Principle, error)
GetAllStates(ctx context.Context) ([]State, error)
GetAllStatuses(ctx context.Context) ([]GetAllStatusesRow, error)
GetAllUsers(ctx context.Context) ([]GetAllUsersRow, error)
GetAttachment(ctx context.Context, id int32) (Attachment, error)
GetBox(ctx context.Context, id int32) (Box, error)
GetCountry(ctx context.Context, id int32) (Country, error)
GetCustomer(ctx context.Context, id int32) (Customer, error)
GetCustomerAddresses(ctx context.Context, customerID int32) ([]GetCustomerAddressesRow, error)
GetCustomerByABN(ctx context.Context, abn sql.NullString) (Customer, error)
GetDocument(ctx context.Context, id int32) (Document, error)
GetDocumentWithUser(ctx context.Context, id int32) (GetDocumentWithUserRow, error)
GetEnquiriesByCustomer(ctx context.Context, arg GetEnquiriesByCustomerParams) ([]GetEnquiriesByCustomerRow, error)
GetEnquiriesByUser(ctx context.Context, arg GetEnquiriesByUserParams) ([]GetEnquiriesByUserRow, error)
GetEnquiry(ctx context.Context, id int32) (GetEnquiryRow, error)
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error)
GetExpiringSoonQuotesOnDay(ctx context.Context, arg GetExpiringSoonQuotesOnDayParams) ([]GetExpiringSoonQuotesOnDayRow, error)
GetLineItem(ctx context.Context, id int32) (LineItem, error)
GetLineItemsByProduct(ctx context.Context, productID sql.NullInt32) ([]LineItem, error)
GetLineItemsTable(ctx context.Context, documentID int32) ([]GetLineItemsTableRow, error)
GetMaxItemNumber(ctx context.Context, documentID int32) (interface{}, error)
GetPrinciple(ctx context.Context, id int32) (Principle, error)
GetPrincipleProducts(ctx context.Context, principleID int32) ([]Product, error)
GetProduct(ctx context.Context, id int32) (Product, error)
GetProductByItemCode(ctx context.Context, itemCode string) (Product, error)
GetProductsByCategory(ctx context.Context, arg GetProductsByCategoryParams) ([]Product, error)
GetPurchaseOrder(ctx context.Context, id int32) (PurchaseOrder, error)
GetPurchaseOrderByDocumentID(ctx context.Context, documentID int32) (PurchaseOrder, error)
GetPurchaseOrderRevisions(ctx context.Context, parentPurchaseOrderID int32) ([]PurchaseOrder, error)
GetPurchaseOrdersByPrinciple(ctx context.Context, arg GetPurchaseOrdersByPrincipleParams) ([]PurchaseOrder, error)
GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error)
GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, error)
GetRecentDocuments(ctx context.Context, limit int32) ([]GetRecentDocumentsRow, error)
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error)
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error)
GetState(ctx context.Context, id int32) (State, error)
GetStatus(ctx context.Context, id int32) (Status, error)
GetUser(ctx context.Context, id int32) (GetUserRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
InsertQuoteReminder(ctx context.Context, arg InsertQuoteReminderParams) (sql.Result, error)
ListAddresses(ctx context.Context, arg ListAddressesParams) ([]Address, error)
ListAddressesByCustomer(ctx context.Context, customerID int32) ([]Address, error)
ListArchivedAttachments(ctx context.Context, arg ListArchivedAttachmentsParams) ([]Attachment, error)
ListArchivedEnquiries(ctx context.Context, arg ListArchivedEnquiriesParams) ([]ListArchivedEnquiriesRow, error)
ListAttachments(ctx context.Context, arg ListAttachmentsParams) ([]Attachment, error)
ListAttachmentsByPrinciple(ctx context.Context, principleID int32) ([]Attachment, error)
ListBoxes(ctx context.Context, arg ListBoxesParams) ([]Box, error)
ListBoxesByShipment(ctx context.Context, shipmentID int32) ([]Box, error)
ListCountries(ctx context.Context, arg ListCountriesParams) ([]Country, error)
ListCustomers(ctx context.Context, arg ListCustomersParams) ([]Customer, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]Document, error)
ListDocumentsByType(ctx context.Context, arg ListDocumentsByTypeParams) ([]Document, error)
ListEnquiries(ctx context.Context, arg ListEnquiriesParams) ([]ListEnquiriesRow, error)
ListLineItems(ctx context.Context, arg ListLineItemsParams) ([]LineItem, error)
ListLineItemsByDocument(ctx context.Context, documentID int32) ([]LineItem, error)
ListPrinciples(ctx context.Context, arg ListPrinciplesParams) ([]Principle, error)
ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error)
ListPurchaseOrders(ctx context.Context, arg ListPurchaseOrdersParams) ([]PurchaseOrder, error)
ListStates(ctx context.Context, arg ListStatesParams) ([]State, error)
ListStatuses(ctx context.Context, arg ListStatusesParams) ([]Status, error)
MarkEnquirySubmitted(ctx context.Context, arg MarkEnquirySubmittedParams) error
SearchCountriesByName(ctx context.Context, concat interface{}) ([]Country, error)
SearchCustomersByName(ctx context.Context, arg SearchCustomersByNameParams) ([]Customer, error)
SearchEnquiries(ctx context.Context, arg SearchEnquiriesParams) ([]SearchEnquiriesRow, error)
SearchProductsByTitle(ctx context.Context, arg SearchProductsByTitleParams) ([]Product, error)
SearchPurchaseOrdersByTitle(ctx context.Context, arg SearchPurchaseOrdersByTitleParams) ([]PurchaseOrder, error)
UnarchiveEnquiry(ctx context.Context, id int32) error
UnarchiveUser(ctx context.Context, id int32) error
UpdateAddress(ctx context.Context, arg UpdateAddressParams) error
UpdateAttachment(ctx context.Context, arg UpdateAttachmentParams) error
UpdateBox(ctx context.Context, arg UpdateBoxParams) error
UpdateCountry(ctx context.Context, arg UpdateCountryParams) error
UpdateCustomer(ctx context.Context, arg UpdateCustomerParams) error
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) error
UpdateEnquiry(ctx context.Context, arg UpdateEnquiryParams) error
UpdateEnquiryStatus(ctx context.Context, arg UpdateEnquiryStatusParams) error
UpdateLineItem(ctx context.Context, arg UpdateLineItemParams) error
UpdateLineItemPrices(ctx context.Context, arg UpdateLineItemPricesParams) error
UpdatePrinciple(ctx context.Context, arg UpdatePrincipleParams) error
UpdateProduct(ctx context.Context, arg UpdateProductParams) error
UpdatePurchaseOrder(ctx context.Context, arg UpdatePurchaseOrderParams) error
UpdateState(ctx context.Context, arg UpdateStateParams) error
UpdateStatus(ctx context.Context, arg UpdateStatusParams) error
UpdateUser(ctx context.Context, arg UpdateUserParams) error
}
var _ Querier = (*Queries)(nil)

View file

@ -0,0 +1,582 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: quotes.sql
package db
import (
"context"
"database/sql"
"time"
)
const disableQuoteReminders = `-- name: DisableQuoteReminders :execresult
UPDATE quotes
SET reminders_disabled = TRUE,
reminders_disabled_at = NOW(),
reminders_disabled_by = ?
WHERE id = ?
`
type DisableQuoteRemindersParams struct {
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
ID int32 `json:"id"`
}
func (q *Queries) DisableQuoteReminders(ctx context.Context, arg DisableQuoteRemindersParams) (sql.Result, error) {
return q.db.ExecContext(ctx, disableQuoteReminders, arg.RemindersDisabledBy, arg.ID)
}
const enableQuoteReminders = `-- name: EnableQuoteReminders :execresult
UPDATE quotes
SET reminders_disabled = FALSE,
reminders_disabled_at = NULL,
reminders_disabled_by = NULL
WHERE id = ?
`
func (q *Queries) EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error) {
return q.db.ExecContext(ctx, enableQuoteReminders, id)
}
const getExpiringSoonQuotes = `-- name: GetExpiringSoonQuotes :many
WITH latest_revision AS (
SELECT
q.enquiry_id,
MAX(d.revision) AS max_revision
FROM quotes q
JOIN documents d ON d.id = q.document_id
GROUP BY q.enquiry_id
),
ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
u.email as user_email,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time,
COALESCE(q.reminders_disabled, FALSE) AS reminders_disabled
FROM quotes q
JOIN documents d ON d.id = q.document_id
LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until
`
type GetExpiringSoonQuotesRow struct {
DocumentID int32 `json:"document_id"`
Username sql.NullString `json:"username"`
UserEmail sql.NullString `json:"user_email"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
RemindersDisabled bool `json:"reminders_disabled"`
}
func (q *Queries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]GetExpiringSoonQuotesRow, error) {
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotes, dateADD)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetExpiringSoonQuotesRow{}
for rows.Next() {
var i GetExpiringSoonQuotesRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.UserEmail,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
&i.RemindersDisabled,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getExpiringSoonQuotesOnDay = `-- name: GetExpiringSoonQuotesOnDay :many
WITH latest_revision AS (
SELECT
q.enquiry_id,
MAX(d.revision) AS max_revision
FROM quotes q
JOIN documents d ON d.id = q.document_id
GROUP BY q.enquiry_id
),
ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
u.email as user_email,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until >= CURRENT_DATE
AND q.valid_until = DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND q.valid_until <= DATE_ADD(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until
`
type GetExpiringSoonQuotesOnDayParams struct {
DATEADD interface{} `json:"DATE_ADD"`
DATEADD_2 interface{} `json:"DATE_ADD_2"`
}
type GetExpiringSoonQuotesOnDayRow struct {
DocumentID int32 `json:"document_id"`
Username sql.NullString `json:"username"`
UserEmail sql.NullString `json:"user_email"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetExpiringSoonQuotesOnDay(ctx context.Context, arg GetExpiringSoonQuotesOnDayParams) ([]GetExpiringSoonQuotesOnDayRow, error) {
rows, err := q.db.QueryContext(ctx, getExpiringSoonQuotesOnDay, arg.DATEADD, arg.DATEADD_2)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetExpiringSoonQuotesOnDayRow{}
for rows.Next() {
var i GetExpiringSoonQuotesOnDayRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.UserEmail,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getQuoteRemindersByType = `-- name: GetQuoteRemindersByType :many
SELECT id, quote_id, reminder_type, date_sent, username
FROM quote_reminders
WHERE quote_id = ? AND reminder_type = ?
ORDER BY date_sent
`
type GetQuoteRemindersByTypeParams struct {
QuoteID int32 `json:"quote_id"`
ReminderType int32 `json:"reminder_type"`
}
func (q *Queries) GetQuoteRemindersByType(ctx context.Context, arg GetQuoteRemindersByTypeParams) ([]QuoteReminder, error) {
rows, err := q.db.QueryContext(ctx, getQuoteRemindersByType, arg.QuoteID, arg.ReminderType)
if err != nil {
return nil, err
}
defer rows.Close()
items := []QuoteReminder{}
for rows.Next() {
var i QuoteReminder
if err := rows.Scan(
&i.ID,
&i.QuoteID,
&i.ReminderType,
&i.DateSent,
&i.Username,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getQuoteRemindersDisabled = `-- name: GetQuoteRemindersDisabled :one
SELECT reminders_disabled, reminders_disabled_at, reminders_disabled_by
FROM quotes
WHERE id = ?
`
type GetQuoteRemindersDisabledRow struct {
RemindersDisabled sql.NullBool `json:"reminders_disabled"`
RemindersDisabledAt sql.NullTime `json:"reminders_disabled_at"`
RemindersDisabledBy sql.NullString `json:"reminders_disabled_by"`
}
func (q *Queries) GetQuoteRemindersDisabled(ctx context.Context, id int32) (GetQuoteRemindersDisabledRow, error) {
row := q.db.QueryRowContext(ctx, getQuoteRemindersDisabled, id)
var i GetQuoteRemindersDisabledRow
err := row.Scan(&i.RemindersDisabled, &i.RemindersDisabledAt, &i.RemindersDisabledBy)
return i, err
}
const getRecentlyExpiredQuotes = `-- name: GetRecentlyExpiredQuotes :many
WITH latest_revision AS (
SELECT
q.enquiry_id,
MAX(d.revision) AS max_revision
FROM quotes q
JOIN documents d ON d.id = q.document_id
GROUP BY q.enquiry_id
),
ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
u.email as user_email,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until >= DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC
`
type GetRecentlyExpiredQuotesRow struct {
DocumentID int32 `json:"document_id"`
Username sql.NullString `json:"username"`
UserEmail sql.NullString `json:"user_email"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesRow, error) {
rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotes, dateSUB)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRecentlyExpiredQuotesRow{}
for rows.Next() {
var i GetRecentlyExpiredQuotesRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.UserEmail,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRecentlyExpiredQuotesOnDay = `-- name: GetRecentlyExpiredQuotesOnDay :many
WITH latest_revision AS (
SELECT
q.enquiry_id,
MAX(d.revision) AS max_revision
FROM quotes q
JOIN documents d ON d.id = q.document_id
GROUP BY q.enquiry_id
),
ranked_reminders AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username,
ROW_NUMBER() OVER (
PARTITION BY quote_id
ORDER BY reminder_type DESC, date_sent DESC
) AS rn
FROM quote_reminders
),
latest_quote_reminder AS (
SELECT
id,
quote_id,
reminder_type,
date_sent,
username
FROM ranked_reminders
WHERE rn = 1
)
SELECT
d.id AS document_id,
u.username,
u.email as user_email,
e.id AS enquiry_id,
e.title as enquiry_ref,
uu.first_name as customer_name,
uu.email as customer_email,
q.date_issued,
q.valid_until,
COALESCE(lqr.reminder_type, 0) AS latest_reminder_type,
COALESCE(lqr.date_sent, CAST('1970-01-01 00:00:00' AS DATETIME)) AS latest_reminder_sent_time
FROM quotes q
JOIN documents d ON d.id = q.document_id
LEFT JOIN users u ON u.id = d.user_id
JOIN enquiries e ON e.id = q.enquiry_id
JOIN users uu ON uu.id = e.contact_user_id
JOIN latest_revision lr ON q.enquiry_id = lr.enquiry_id AND d.revision = lr.max_revision
LEFT JOIN latest_quote_reminder lqr on d.id = lqr.quote_id
WHERE
q.valid_until < CURRENT_DATE
AND valid_until = DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
AND e.status_id = 5
AND (q.reminders_disabled IS NULL OR q.reminders_disabled = FALSE)
ORDER BY q.valid_until DESC
`
type GetRecentlyExpiredQuotesOnDayRow struct {
DocumentID int32 `json:"document_id"`
Username sql.NullString `json:"username"`
UserEmail sql.NullString `json:"user_email"`
EnquiryID int32 `json:"enquiry_id"`
EnquiryRef string `json:"enquiry_ref"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
DateIssued time.Time `json:"date_issued"`
ValidUntil time.Time `json:"valid_until"`
LatestReminderType int32 `json:"latest_reminder_type"`
LatestReminderSentTime time.Time `json:"latest_reminder_sent_time"`
}
func (q *Queries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]GetRecentlyExpiredQuotesOnDayRow, error) {
rows, err := q.db.QueryContext(ctx, getRecentlyExpiredQuotesOnDay, dateSUB)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRecentlyExpiredQuotesOnDayRow{}
for rows.Next() {
var i GetRecentlyExpiredQuotesOnDayRow
if err := rows.Scan(
&i.DocumentID,
&i.Username,
&i.UserEmail,
&i.EnquiryID,
&i.EnquiryRef,
&i.CustomerName,
&i.CustomerEmail,
&i.DateIssued,
&i.ValidUntil,
&i.LatestReminderType,
&i.LatestReminderSentTime,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertQuoteReminder = `-- name: InsertQuoteReminder :execresult
INSERT INTO quote_reminders (quote_id, reminder_type, date_sent, username)
VALUES (?, ?, ?, ?)
`
type InsertQuoteReminderParams struct {
QuoteID int32 `json:"quote_id"`
ReminderType int32 `json:"reminder_type"`
DateSent time.Time `json:"date_sent"`
Username sql.NullString `json:"username"`
}
func (q *Queries) InsertQuoteReminder(ctx context.Context, arg InsertQuoteReminderParams) (sql.Result, error) {
return q.db.ExecContext(ctx, insertQuoteReminder,
arg.QuoteID,
arg.ReminderType,
arg.DateSent,
arg.Username,
)
}

View file

@ -0,0 +1,257 @@
package email
import (
"bytes"
"crypto/tls"
"encoding/base64"
"fmt"
"html/template"
"io"
"net/smtp"
"os"
"strconv"
"sync"
)
// Attachment represents an email attachment
type Attachment struct {
Filename string
FilePath string
}
var (
emailServiceInstance *EmailService
once sync.Once
)
// EmailService provides methods to send templated emails via SMTP.
type EmailService struct {
SMTPHost string
SMTPPort int
Username string
Password string
FromAddress string
}
// GetEmailService returns a singleton EmailService loaded from environment variables
func GetEmailService() *EmailService {
once.Do(func() {
host := os.Getenv("SMTP_HOST")
portStr := os.Getenv("SMTP_PORT")
port, err := strconv.Atoi(portStr)
if err != nil {
port = 25 // default SMTP port
}
username := os.Getenv("SMTP_USER")
password := os.Getenv("SMTP_PASS")
from := os.Getenv("SMTP_FROM")
emailServiceInstance = &EmailService{
SMTPHost: host,
SMTPPort: port,
Username: username,
Password: password,
FromAddress: from,
}
})
return emailServiceInstance
}
// SendTemplateEmail renders a template and sends an email with optional CC and BCC.
func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
return es.SendTemplateEmailWithAttachments(to, subject, templateName, data, ccs, bccs, nil)
}
// SendTemplateEmailWithAttachments renders a template and sends an email with optional CC, BCC, and attachments.
func (es *EmailService) SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error {
// Convert interface{} attachments to []Attachment
var typedAttachments []Attachment
for _, att := range attachments {
if a, ok := att.(Attachment); ok {
typedAttachments = append(typedAttachments, a)
} else if a, ok := att.(struct{ Filename, FilePath string }); ok {
typedAttachments = append(typedAttachments, Attachment{Filename: a.Filename, FilePath: a.FilePath})
}
}
defaultBccs := []string{"carpis@cmctechnologies.com.au"}
bccs = append(defaultBccs, bccs...)
const templateDir = "templates/quotes"
tmplPath := fmt.Sprintf("%s/%s", templateDir, templateName)
tmpl, err := template.ParseFiles(tmplPath)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
var body bytes.Buffer
if err := tmpl.Execute(&body, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
var msg bytes.Buffer
// If there are attachments, use multipart message
if len(typedAttachments) > 0 {
boundary := "boundary123456789"
// Write headers
fmt.Fprintf(&msg, "From: %s\r\n", es.FromAddress)
fmt.Fprintf(&msg, "To: %s\r\n", to)
if len(ccs) > 0 {
fmt.Fprintf(&msg, "Cc: %s\r\n", joinAddresses(ccs))
}
fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n", boundary)
msg.WriteString("\r\n")
// Write HTML body part
fmt.Fprintf(&msg, "--%s\r\n", boundary)
msg.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
msg.WriteString("\r\n")
msg.Write(body.Bytes())
msg.WriteString("\r\n")
// Write attachments
for _, att := range typedAttachments {
file, err := os.Open(att.FilePath)
if err != nil {
return fmt.Errorf("failed to open attachment %s: %w", att.FilePath, err)
}
fileData, err := io.ReadAll(file)
file.Close()
if err != nil {
return fmt.Errorf("failed to read attachment %s: %w", att.FilePath, err)
}
fmt.Fprintf(&msg, "--%s\r\n", boundary)
fmt.Fprintf(&msg, "Content-Type: application/pdf\r\n")
fmt.Fprintf(&msg, "Content-Transfer-Encoding: base64\r\n")
fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n", att.Filename)
msg.WriteString("\r\n")
encoded := base64.StdEncoding.EncodeToString(fileData)
// Split into lines of 76 characters for proper MIME formatting
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
msg.WriteString(encoded[i:end])
msg.WriteString("\r\n")
}
}
fmt.Fprintf(&msg, "--%s--\r\n", boundary)
} else {
// Simple message without attachments
headers := make(map[string]string)
headers["From"] = es.FromAddress
headers["To"] = to
if len(ccs) > 0 {
headers["Cc"] = joinAddresses(ccs)
}
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
for k, v := range headers {
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
}
msg.WriteString("\r\n")
msg.Write(body.Bytes())
}
recipients := []string{to}
recipients = append(recipients, ccs...)
recipients = append(recipients, bccs...)
smtpAddr := fmt.Sprintf("%s:%d", es.SMTPHost, es.SMTPPort)
// If no username/password, assume no auth or TLS (e.g., MailHog)
if es.Username == "" && es.Password == "" {
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
auth := smtp.PlainAuth("", es.Username, es.Password, es.SMTPHost)
// Establish connection to SMTP server
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
// Upgrade to TLS if supported (STARTTLS)
tlsconfig := &tls.Config{
ServerName: es.SMTPHost,
}
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(tlsconfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
// joinAddresses joins email addresses with a comma and space.
func joinAddresses(addrs []string) string {
return fmt.Sprintf("%s", bytes.Join([][]byte(func() [][]byte {
b := make([][]byte, len(addrs))
for i, a := range addrs {
b[i] = []byte(a)
}
return b
}()), []byte(", ")))
}

View file

@ -0,0 +1,272 @@
package attachments
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"github.com/gorilla/mux"
)
type AttachmentHandler struct {
queries *db.Queries
}
func NewAttachmentHandler(queries *db.Queries) *AttachmentHandler {
return &AttachmentHandler{queries: queries}
}
func (h *AttachmentHandler) List(w http.ResponseWriter, r *http.Request) {
limit := 50
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if val, err := strconv.Atoi(l); err == nil {
limit = val
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if val, err := strconv.Atoi(o); err == nil {
offset = val
}
}
attachments, err := h.queries.ListAttachments(r.Context(), db.ListAttachmentsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(attachments)
}
func (h *AttachmentHandler) Archived(w http.ResponseWriter, r *http.Request) {
limit := 50
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if val, err := strconv.Atoi(l); err == nil {
limit = val
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if val, err := strconv.Atoi(o); err == nil {
offset = val
}
}
attachments, err := h.queries.ListArchivedAttachments(r.Context(), db.ListArchivedAttachmentsParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(attachments)
}
func (h *AttachmentHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
return
}
attachment, err := h.queries.GetAttachment(r.Context(), int32(id))
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Attachment not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(attachment)
}
func (h *AttachmentHandler) Create(w http.ResponseWriter, r *http.Request) {
// Parse multipart form
err := r.ParseMultipartForm(32 << 20) // 32MB max
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Get file from form - try both field names (CakePHP format and plain)
file, handler, err := r.FormFile("data[Attachment][file]")
if err != nil {
// Try plain "file" field name as fallback
file, handler, err = r.FormFile("file")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
return
}
}
defer file.Close()
// Generate unique filename
ext := filepath.Ext(handler.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), handler.Filename[:len(handler.Filename)-len(ext)], ext)
// Create attachments directory if it doesn't exist
attachDir := "webroot/attachments_files"
if err := os.MkdirAll(attachDir, 0755); err != nil {
http.Error(w, "Failed to create attachments directory", http.StatusInternalServerError)
return
}
// Save file to disk
filePath := filepath.Join(attachDir, filename)
dst, err := os.Create(filePath)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Parse principle_id - try CakePHP format first, then fallback
principleID := 1 // Default
pid := r.FormValue("data[Attachment][principle_id]")
if pid == "" {
pid = r.FormValue("principle_id")
}
if pid != "" {
if id, err := strconv.Atoi(pid); err == nil {
principleID = id
}
}
// Get other form values - try CakePHP format first, then fallback
name := r.FormValue("data[Attachment][name]")
if name == "" {
name = r.FormValue("name")
}
description := r.FormValue("data[Attachment][description]")
if description == "" {
description = r.FormValue("description")
}
// Create database record
// Store path in PHP format: /var/www/cmc-sales/app/webroot/attachments_files/filename
phpPath := "/var/www/cmc-sales/app/webroot/attachments_files/" + filename
params := db.CreateAttachmentParams{
PrincipleID: int32(principleID),
Name: name,
Filename: handler.Filename,
File: phpPath, // Store PHP container path for compatibility
Type: handler.Header.Get("Content-Type"),
Size: int32(handler.Size),
Description: description,
}
if params.Name == "" {
params.Name = handler.Filename
}
result, err := h.queries.CreateAttachment(r.Context(), params)
if err != nil {
// Clean up file on error
os.Remove(filePath)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, err := result.LastInsertId()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// If HTMX request, return success message
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<div class="notification is-success">Attachment uploaded successfully</div>`))
return
}
// JSON response for API
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
})
}
func (h *AttachmentHandler) Update(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
return
}
var params db.UpdateAttachmentParams
if r.Header.Get("Content-Type") == "application/json" {
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
} else {
// Handle form data
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
params = db.UpdateAttachmentParams{
Name: r.FormValue("name"),
Description: r.FormValue("description"),
}
}
params.ID = int32(id)
if err := h.queries.UpdateAttachment(r.Context(), params); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *AttachmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid attachment ID", http.StatusBadRequest)
return
}
// Soft delete (archive)
if err := h.queries.DeleteAttachment(r.Context(), int32(id)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,37 @@
package attachments
import (
"testing"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
)
// TestNewAttachmentHandler tests that the handler is created correctly
func TestNewAttachmentHandler(t *testing.T) {
queries := &db.Queries{}
handler := NewAttachmentHandler(queries)
if handler == nil {
t.Fatal("Expected handler to be created, got nil")
}
if handler.queries != queries {
t.Fatal("Expected handler.queries to match input queries")
}
}
// Note: Full integration tests with database mocking would require more setup.
// For now, this provides basic structure validation.
// To run full tests, you would need to:
// 1. Create a test database or use an in-memory SQLite database
// 2. Run migrations
// 3. Test each handler method with actual database calls
//
// Example test structure for future expansion:
// func TestListAttachments(t *testing.T) {
// db := setupTestDB(t)
// defer db.Close()
// queries := db.New(db)
// handler := NewAttachmentHandler(queries)
// // ... test implementation
// }

View file

@ -0,0 +1,870 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"strconv"
"strings"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
type EmailHandler struct {
queries *db.Queries
db *sql.DB
gmailService *gmail.Service
}
type EmailResponse struct {
ID int32 `json:"id"`
Subject string `json:"subject"`
UserID int32 `json:"user_id"`
Created time.Time `json:"created"`
GmailMessageID *string `json:"gmail_message_id,omitempty"`
AttachmentCount int32 `json:"attachment_count"`
IsDownloaded *bool `json:"is_downloaded,omitempty"`
}
type EmailDetailResponse struct {
ID int32 `json:"id"`
Subject string `json:"subject"`
UserID int32 `json:"user_id"`
Created time.Time `json:"created"`
GmailMessageID *string `json:"gmail_message_id,omitempty"`
GmailThreadID *string `json:"gmail_thread_id,omitempty"`
RawHeaders *string `json:"raw_headers,omitempty"`
IsDownloaded *bool `json:"is_downloaded,omitempty"`
Enquiries []int32 `json:"enquiries,omitempty"`
Invoices []int32 `json:"invoices,omitempty"`
PurchaseOrders []int32 `json:"purchase_orders,omitempty"`
Jobs []int32 `json:"jobs,omitempty"`
}
type EmailAttachmentResponse struct {
ID int32 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Size int32 `json:"size"`
Filename string `json:"filename"`
IsMessageBody bool `json:"is_message_body"`
GmailAttachmentID *string `json:"gmail_attachment_id,omitempty"`
Created time.Time `json:"created"`
}
func NewEmailHandler(queries *db.Queries, database *sql.DB) *EmailHandler {
// Try to initialize Gmail service
gmailService, err := getGmailService("credentials.json", "token.json")
if err != nil {
// Log the error but continue without Gmail service
fmt.Printf("Warning: Gmail service not available: %v\n", err)
}
return &EmailHandler{
queries: queries,
db: database,
gmailService: gmailService,
}
}
// List emails with pagination and filtering
func (h *EmailHandler) List(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
limitStr := r.URL.Query().Get("limit")
offsetStr := r.URL.Query().Get("offset")
search := r.URL.Query().Get("search")
userID := r.URL.Query().Get("user_id")
// Set defaults
limit := 50
offset := 0
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
// Build query
query := `
SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id, e.email_attachment_count, e.is_downloaded
FROM emails e`
var args []interface{}
var conditions []string
if search != "" {
conditions = append(conditions, "e.subject LIKE ?")
args = append(args, "%"+search+"%")
}
if userID != "" {
conditions = append(conditions, "e.user_id = ?")
args = append(args, userID)
}
if len(conditions) > 0 {
query += " WHERE " + joinConditions(conditions, " AND ")
}
query += " ORDER BY e.id DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := h.db.Query(query, args...)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
var emails []EmailResponse
for rows.Next() {
var email EmailResponse
var gmailMessageID sql.NullString
var isDownloaded sql.NullBool
err := rows.Scan(
&email.ID,
&email.Subject,
&email.UserID,
&email.Created,
&gmailMessageID,
&email.AttachmentCount,
&isDownloaded,
)
if err != nil {
continue
}
if gmailMessageID.Valid {
email.GmailMessageID = &gmailMessageID.String
}
if isDownloaded.Valid {
email.IsDownloaded = &isDownloaded.Bool
}
emails = append(emails, email)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(emails)
}
// Get a specific email with details
func (h *EmailHandler) Get(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
emailID, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid email ID", http.StatusBadRequest)
return
}
// Get email details
query := `
SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id,
e.gmail_thread_id, e.raw_headers, e.is_downloaded
FROM emails e
WHERE e.id = ?`
var email EmailDetailResponse
var gmailMessageID, gmailThreadID, rawHeaders sql.NullString
var isDownloaded sql.NullBool
err = h.db.QueryRow(query, emailID).Scan(
&email.ID,
&email.Subject,
&email.UserID,
&email.Created,
&gmailMessageID,
&gmailThreadID,
&rawHeaders,
&isDownloaded,
)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Email not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
}
return
}
if gmailMessageID.Valid {
email.GmailMessageID = &gmailMessageID.String
}
if gmailThreadID.Valid {
email.GmailThreadID = &gmailThreadID.String
}
if rawHeaders.Valid {
email.RawHeaders = &rawHeaders.String
}
if isDownloaded.Valid {
email.IsDownloaded = &isDownloaded.Bool
}
// Get associated enquiries
enquiryRows, err := h.db.Query("SELECT enquiry_id FROM emails_enquiries WHERE email_id = ?", emailID)
if err == nil {
defer enquiryRows.Close()
for enquiryRows.Next() {
var enquiryID int32
if enquiryRows.Scan(&enquiryID) == nil {
email.Enquiries = append(email.Enquiries, enquiryID)
}
}
}
// Get associated invoices
invoiceRows, err := h.db.Query("SELECT invoice_id FROM emails_invoices WHERE email_id = ?", emailID)
if err == nil {
defer invoiceRows.Close()
for invoiceRows.Next() {
var invoiceID int32
if invoiceRows.Scan(&invoiceID) == nil {
email.Invoices = append(email.Invoices, invoiceID)
}
}
}
// Get associated purchase orders
poRows, err := h.db.Query("SELECT purchase_order_id FROM emails_purchase_orders WHERE email_id = ?", emailID)
if err == nil {
defer poRows.Close()
for poRows.Next() {
var poID int32
if poRows.Scan(&poID) == nil {
email.PurchaseOrders = append(email.PurchaseOrders, poID)
}
}
}
// Get associated jobs
jobRows, err := h.db.Query("SELECT job_id FROM emails_jobs WHERE email_id = ?", emailID)
if err == nil {
defer jobRows.Close()
for jobRows.Next() {
var jobID int32
if jobRows.Scan(&jobID) == nil {
email.Jobs = append(email.Jobs, jobID)
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(email)
}
// List attachments for an email
func (h *EmailHandler) ListAttachments(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
emailID, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid email ID", http.StatusBadRequest)
return
}
// First check if attachments are already in database
query := `
SELECT id, name, type, size, filename, is_message_body, gmail_attachment_id, created
FROM email_attachments
WHERE email_id = ?
ORDER BY is_message_body DESC, created ASC`
rows, err := h.db.Query(query, emailID)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
var attachments []EmailAttachmentResponse
hasStoredAttachments := false
for rows.Next() {
hasStoredAttachments = true
var attachment EmailAttachmentResponse
var gmailAttachmentID sql.NullString
err := rows.Scan(
&attachment.ID,
&attachment.Name,
&attachment.Type,
&attachment.Size,
&attachment.Filename,
&attachment.IsMessageBody,
&gmailAttachmentID,
&attachment.Created,
)
if err != nil {
continue
}
if gmailAttachmentID.Valid {
attachment.GmailAttachmentID = &gmailAttachmentID.String
}
attachments = append(attachments, attachment)
}
// If no stored attachments and this is a Gmail email, try to fetch from Gmail
if !hasStoredAttachments && h.gmailService != nil {
// Get Gmail message ID
var gmailMessageID sql.NullString
err := h.db.QueryRow("SELECT gmail_message_id FROM emails WHERE id = ?", emailID).Scan(&gmailMessageID)
if err == nil && gmailMessageID.Valid {
// Fetch message metadata from Gmail
message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String).
Format("FULL").Do()
if err == nil && message.Payload != nil {
// Extract attachment info from Gmail message
attachmentIndex := int32(1)
h.extractGmailAttachments(message.Payload, &attachments, &attachmentIndex)
}
}
}
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
// Return HTML for HTMX
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if len(attachments) == 0 {
// No attachments found
html := `<div class="notification is-light">
<p class="has-text-grey">No attachments found for this email.</p>
</div>`
w.Write([]byte(html))
return
}
// Build HTML table for attachments
var htmlBuilder strings.Builder
htmlBuilder.WriteString(`<div class="box">
<h3 class="title is-5">Attachments</h3>
<div class="table-container">
<table class="table is-fullwidth">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>`)
for _, att := range attachments {
icon := `<i class="fas fa-paperclip"></i>`
if att.IsMessageBody {
icon = `<i class="fas fa-envelope"></i>`
}
downloadURL := fmt.Sprintf("/api/v1/emails/%d/attachments/%d", emailID, att.ID)
if att.GmailAttachmentID != nil {
downloadURL = fmt.Sprintf("/api/v1/emails/%d/attachments/%d/stream", emailID, att.ID)
}
htmlBuilder.WriteString(fmt.Sprintf(`
<tr>
<td>
<span class="icon has-text-grey">%s</span>
%s
</td>
<td><span class="tag is-light">%s</span></td>
<td>%d bytes</td>
<td>
<a href="%s" target="_blank" class="button is-small is-info">
<span class="icon"><i class="fas fa-download"></i></span>
<span>Download</span>
</a>
</td>
</tr>`, icon, att.Name, att.Type, att.Size, downloadURL))
}
htmlBuilder.WriteString(`
</tbody>
</table>
</div>
</div>`)
w.Write([]byte(htmlBuilder.String()))
return
}
// Return JSON for API requests
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(attachments)
}
// Helper function to extract attachment info from Gmail message parts
func (h *EmailHandler) extractGmailAttachments(part *gmail.MessagePart, attachments *[]EmailAttachmentResponse, index *int32) {
// Check if this part is an attachment
// Some attachments may not have filenames or may be inline
if part.Body != nil && part.Body.AttachmentId != "" {
filename := part.Filename
if filename == "" {
// Try to generate a filename from content type
switch part.MimeType {
case "application/pdf":
filename = "attachment.pdf"
case "image/png":
filename = "image.png"
case "image/jpeg":
filename = "image.jpg"
case "text/plain":
filename = "text.txt"
default:
filename = "attachment"
}
}
attachment := EmailAttachmentResponse{
ID: *index,
Name: filename,
Type: part.MimeType,
Size: int32(part.Body.Size),
Filename: filename,
IsMessageBody: false,
GmailAttachmentID: &part.Body.AttachmentId,
Created: time.Now(), // Use current time as placeholder
}
*attachments = append(*attachments, attachment)
*index++
}
// Process sub-parts
for _, subPart := range part.Parts {
h.extractGmailAttachments(subPart, attachments, index)
}
}
// Search emails
func (h *EmailHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Search query is required", http.StatusBadRequest)
return
}
// Parse optional parameters
limitStr := r.URL.Query().Get("limit")
limit := 20
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
// Search in subjects and headers
sqlQuery := `
SELECT e.id, e.subject, e.user_id, e.created, e.gmail_message_id, e.email_attachment_count, e.is_downloaded
FROM emails e
WHERE e.subject LIKE ? OR e.raw_headers LIKE ?
ORDER BY e.id DESC
LIMIT ?`
searchTerm := "%" + query + "%"
rows, err := h.db.Query(sqlQuery, searchTerm, searchTerm, limit)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
var emails []EmailResponse
for rows.Next() {
var email EmailResponse
var gmailMessageID sql.NullString
var isDownloaded sql.NullBool
err := rows.Scan(
&email.ID,
&email.Subject,
&email.UserID,
&email.Created,
&gmailMessageID,
&email.AttachmentCount,
&isDownloaded,
)
if err != nil {
continue
}
if gmailMessageID.Valid {
email.GmailMessageID = &gmailMessageID.String
}
if isDownloaded.Valid {
email.IsDownloaded = &isDownloaded.Bool
}
emails = append(emails, email)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(emails)
}
// Stream email content from Gmail
func (h *EmailHandler) StreamContent(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
emailID, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid email ID", http.StatusBadRequest)
return
}
// Get email details to check if it's a Gmail email
query := `
SELECT e.gmail_message_id, e.subject, e.created, e.user_id
FROM emails e
WHERE e.id = ?`
var gmailMessageID sql.NullString
var subject string
var created time.Time
var userID int32
err = h.db.QueryRow(query, emailID).Scan(&gmailMessageID, &subject, &created, &userID)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Email not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
}
return
}
if !gmailMessageID.Valid {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := `
<div class="notification is-warning">
<strong>Local Email</strong><br>
This email is not from Gmail and does not have stored content available for display.
</div>`
w.Write([]byte(html))
return
}
// Check for stored message body content in attachments
attachmentQuery := `
SELECT id, name, type, size
FROM email_attachments
WHERE email_id = ? AND is_message_body = 1
ORDER BY created ASC`
attachmentRows, err := h.db.Query(attachmentQuery, emailID)
if err == nil {
defer attachmentRows.Close()
if attachmentRows.Next() {
var attachmentID int32
var name, attachmentType string
var size int32
if attachmentRows.Scan(&attachmentID, &name, &attachmentType, &size) == nil {
// Found stored message body - would normally read the content from file storage
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(`
<div class="content">
<div class="notification is-success is-light">
<strong>Stored Email Content</strong><br>
Message body is stored locally as attachment: %s (%s, %d bytes)
</div>
<div class="box">
<p><em>Content would be loaded from local storage here.</em></p>
<p>Attachment ID: %d</p>
</div>
</div>`, name, attachmentType, size, attachmentID)
w.Write([]byte(html))
return
}
}
}
// Try to fetch from Gmail if service is available
if h.gmailService != nil {
// Fetch from Gmail
message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String).
Format("RAW").Do()
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(`
<div class="notification is-danger">
<strong>Gmail API Error</strong><br>
Failed to fetch email from Gmail: %v
</div>`, err)
w.Write([]byte(html))
return
}
// Decode message
rawEmail, err := base64.URLEncoding.DecodeString(message.Raw)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(`
<div class="notification is-danger">
<strong>Decode Error</strong><br>
Failed to decode email: %v
</div>`, err)
w.Write([]byte(html))
return
}
// Parse with enmime
env, err := enmime.ReadEnvelope(bytes.NewReader(rawEmail))
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(`
<div class="notification is-danger">
<strong>Parse Error</strong><br>
Failed to parse email: %v
</div>`, err)
w.Write([]byte(html))
return
}
// Stream HTML or Text directly to client
if env.HTML != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(env.HTML))
} else if env.Text != "" {
// Convert plain text to HTML for better display
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(`
<div class="content">
<div class="notification is-info is-light">
<strong>Plain Text Email</strong><br>
This email contains only plain text content.
</div>
<div class="box">
<pre style="white-space: pre-wrap; font-family: inherit;">%s</pre>
</div>
</div>`, env.Text)
w.Write([]byte(html))
} else {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := `
<div class="notification is-warning">
<strong>No Content</strong><br>
No HTML or text content found in this email.
</div>`
w.Write([]byte(html))
}
return
}
// No Gmail service available - show error
w.Header().Set("Content-Type", "text/html; charset=utf-8")
html := fmt.Sprintf(`
<div class="content">
<div class="notification is-warning is-light">
<strong>Gmail Service Unavailable</strong><br>
<small>Subject: %s</small><br>
<small>Date: %s</small><br>
<small>Gmail Message ID: <code>%s</code></small>
</div>
<div class="box">
<div class="content">
<h4>Integration Status</h4>
<p>Gmail service is not available. To enable email content display:</p>
<ol>
<li>Ensure <code>credentials.json</code> and <code>token.json</code> files are present</li>
<li>Configure Gmail API OAuth2 authentication</li>
<li>Restart the application</li>
</ol>
<p class="has-text-grey-light is-size-7">
<strong>Gmail Message ID:</strong> <code>%s</code>
</p>
</div>
</div>
</div>`,
subject,
created.Format("2006-01-02 15:04:05"),
gmailMessageID.String,
gmailMessageID.String)
w.Write([]byte(html))
}
// Stream attachment from Gmail
func (h *EmailHandler) StreamAttachment(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
emailID, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid email ID", http.StatusBadRequest)
return
}
attachmentID := vars["attachmentId"]
// Get email's Gmail message ID
var gmailMessageID sql.NullString
err = h.db.QueryRow("SELECT gmail_message_id FROM emails WHERE id = ?", emailID).Scan(&gmailMessageID)
if err != nil || !gmailMessageID.Valid {
http.Error(w, "Email not found or not a Gmail email", http.StatusNotFound)
return
}
if h.gmailService == nil {
http.Error(w, "Gmail service not available", http.StatusServiceUnavailable)
return
}
// For dynamic attachments, we need to fetch the message and find the attachment
message, err := h.gmailService.Users.Messages.Get("me", gmailMessageID.String).
Format("FULL").Do()
if err != nil {
http.Error(w, "Failed to fetch email from Gmail", http.StatusInternalServerError)
return
}
// Find the attachment by index
var targetAttachment *gmail.MessagePart
attachmentIndex := 1
findAttachment(message.Payload, attachmentID, &attachmentIndex, &targetAttachment)
if targetAttachment == nil || targetAttachment.Body == nil || targetAttachment.Body.AttachmentId == "" {
http.Error(w, "Attachment not found", http.StatusNotFound)
return
}
// Fetch attachment data from Gmail
attachment, err := h.gmailService.Users.Messages.Attachments.
Get("me", gmailMessageID.String, targetAttachment.Body.AttachmentId).Do()
if err != nil {
http.Error(w, "Failed to fetch attachment from Gmail", http.StatusInternalServerError)
return
}
// Decode base64
data, err := base64.URLEncoding.DecodeString(attachment.Data)
if err != nil {
http.Error(w, "Failed to decode attachment", http.StatusInternalServerError)
return
}
// Set headers and stream
filename := targetAttachment.Filename
if filename == "" {
// Generate filename from content type (same logic as extractGmailAttachments)
switch targetAttachment.MimeType {
case "application/pdf":
filename = "attachment.pdf"
case "image/png":
filename = "image.png"
case "image/jpeg":
filename = "image.jpg"
case "text/plain":
filename = "text.txt"
default:
filename = "attachment"
}
}
w.Header().Set("Content-Type", targetAttachment.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.Write(data)
}
// Helper function to find attachment by index
func findAttachment(part *gmail.MessagePart, targetID string, currentIndex *int, result **gmail.MessagePart) {
// Check if this part is an attachment (same logic as extractGmailAttachments)
if part.Body != nil && part.Body.AttachmentId != "" {
fmt.Printf("Checking attachment %d (looking for %s): %s\n", *currentIndex, targetID, part.Filename)
if strconv.Itoa(*currentIndex) == targetID {
fmt.Printf("Found matching attachment!\n")
*result = part
return
}
*currentIndex++
}
for _, subPart := range part.Parts {
findAttachment(subPart, targetID, currentIndex, result)
if *result != nil {
return
}
}
}
// Helper function to join conditions
func joinConditions(conditions []string, separator string) string {
if len(conditions) == 0 {
return ""
}
if len(conditions) == 1 {
return conditions[0]
}
result := conditions[0]
for i := 1; i < len(conditions); i++ {
result += separator + conditions[i]
}
return result
}
// Gmail OAuth2 functions
func getGmailService(credentialsFile, tokenFile string) (*gmail.Service, error) {
ctx := context.Background()
b, err := ioutil.ReadFile(credentialsFile)
if err != nil {
return nil, fmt.Errorf("unable to read client secret file: %v", err)
}
config, err := google.ConfigFromJSON(b, gmail.GmailReadonlyScope)
if err != nil {
return nil, fmt.Errorf("unable to parse client secret file to config: %v", err)
}
client := getClient(config, tokenFile)
srv, err := gmail.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return nil, fmt.Errorf("unable to retrieve Gmail client: %v", err)
}
return srv, nil
}
func getClient(config *oauth2.Config, tokFile string) *http.Client {
tok, err := tokenFromFile(tokFile)
if err != nil {
return nil
}
return config.Client(context.Background(), tok)
}
func tokenFromFile(file string) (*oauth2.Token, error) {
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
tok := &oauth2.Token{}
err = json.NewDecoder(f).Decode(tok)
return tok, err
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,841 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/auth"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/templates"
)
// getUsername is a wrapper around auth.GetUsername for backwards compatibility
func getUsername(r *http.Request) string {
return auth.GetUsername(r)
}
// Helper: returns date string or empty if zero
func formatDate(t time.Time) string {
if t.IsZero() || t.Year() == 1970 {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// Helper: checks if a time is a valid DB value (not zero or 1970-01-01)
func isValidDBTime(t time.Time) bool {
return !t.IsZero() && t.After(time.Date(1971, 1, 1, 0, 0, 0, 0, time.UTC))
}
// calcExpiryInfo is a helper to calculate expiry info for a quote
func calcExpiryInfo(validUntil time.Time) (string, int, int) {
now := time.Now()
nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
validUntilDate := time.Date(validUntil.Year(), validUntil.Month(), validUntil.Day(), 0, 0, 0, 0, validUntil.Location())
daysUntil := int(validUntilDate.Sub(nowDate).Hours() / 24)
daysSince := int(nowDate.Sub(validUntilDate).Hours() / 24)
var relative string
if validUntilDate.After(nowDate) || validUntilDate.Equal(nowDate) {
switch daysUntil {
case 0:
relative = "expires today"
case 1:
relative = "expires tomorrow"
default:
relative = "expires in " + strconv.Itoa(daysUntil) + " days"
}
} else {
switch daysSince {
case 0:
relative = "expired today"
case 1:
relative = "expired yesterday"
default:
relative = "expired " + strconv.Itoa(daysSince) + " days ago"
}
}
return relative, daysUntil, daysSince
}
// QuoteRow interface for all quote row types
// (We use wrapper types since sqlc structs can't be modified directly)
type QuoteRow interface {
GetID() int32
GetUsername() string
GetUserEmail() string
GetEnquiryID() int32
GetEnquiryRef() string
GetDateIssued() time.Time
GetValidUntil() time.Time
GetReminderType() int32
GetReminderSent() time.Time
GetCustomerName() string
GetCustomerEmail() string
}
// Wrapper types for each DB row struct
type ExpiringSoonQuoteRowWrapper struct{ db.GetExpiringSoonQuotesRow }
func (q ExpiringSoonQuoteRowWrapper) GetID() int32 { return q.DocumentID }
func (q ExpiringSoonQuoteRowWrapper) GetUsername() string {
if q.Username.Valid {
return q.Username.String
}
return "-"
}
func (q ExpiringSoonQuoteRowWrapper) GetUserEmail() string {
if q.UserEmail.Valid {
return q.UserEmail.String
}
return ""
}
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q ExpiringSoonQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q ExpiringSoonQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q ExpiringSoonQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q ExpiringSoonQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q ExpiringSoonQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
func (q ExpiringSoonQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q ExpiringSoonQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type RecentlyExpiredQuoteRowWrapper struct{ db.GetRecentlyExpiredQuotesRow }
func (q RecentlyExpiredQuoteRowWrapper) GetID() int32 { return q.DocumentID }
func (q RecentlyExpiredQuoteRowWrapper) GetUsername() string {
if q.Username.Valid {
return q.Username.String
}
return "-"
}
func (q RecentlyExpiredQuoteRowWrapper) GetUserEmail() string {
if q.UserEmail.Valid {
return q.UserEmail.String
}
return ""
}
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q RecentlyExpiredQuoteRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q RecentlyExpiredQuoteRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q RecentlyExpiredQuoteRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q RecentlyExpiredQuoteRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q RecentlyExpiredQuoteRowWrapper) GetReminderSent() time.Time { return q.LatestReminderSentTime }
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q RecentlyExpiredQuoteRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type ExpiringSoonQuoteOnDayRowWrapper struct {
db.GetExpiringSoonQuotesOnDayRow
}
func (q ExpiringSoonQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetUsername() string {
if q.Username.Valid {
return q.Username.String
}
return "-"
}
func (q ExpiringSoonQuoteOnDayRowWrapper) GetUserEmail() string {
if q.UserEmail.Valid {
return q.UserEmail.String
}
return ""
}
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetReminderSent() time.Time {
return q.LatestReminderSentTime
}
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q ExpiringSoonQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
type RecentlyExpiredQuoteOnDayRowWrapper struct {
db.GetRecentlyExpiredQuotesOnDayRow
}
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetID() int32 { return q.DocumentID }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUsername() string {
if q.Username.Valid {
return q.Username.String
}
return "Unknown"
}
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetUserEmail() string {
if q.UserEmail.Valid {
return q.UserEmail.String
}
return ""
}
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryID() int32 { return q.EnquiryID }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetEnquiryRef() string { return q.EnquiryRef }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetDateIssued() time.Time { return q.DateIssued }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetValidUntil() time.Time { return q.ValidUntil }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderType() int32 { return q.LatestReminderType }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetReminderSent() time.Time {
return q.LatestReminderSentTime
}
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerName() string { return q.CustomerName }
func (q RecentlyExpiredQuoteOnDayRowWrapper) GetCustomerEmail() string { return q.CustomerEmail }
// Helper: formats a quote row for output (generic)
func formatQuoteRow(q QuoteRow) map[string]interface{} {
relative, daysUntil, daysSince := calcExpiryInfo(q.GetValidUntil())
return map[string]interface{}{
"ID": q.GetID(),
"Username": strings.Title(q.GetUsername()),
"UserEmail": q.GetUserEmail(),
"EnquiryID": q.GetEnquiryID(),
"EnquiryRef": q.GetEnquiryRef(),
"CustomerName": strings.TrimSpace(q.GetCustomerName()),
"CustomerEmail": q.GetCustomerEmail(),
"DateIssued": formatDate(q.GetDateIssued()),
"ValidUntil": formatDate(q.GetValidUntil()),
"ValidUntilRelative": relative,
"DaysUntilExpiry": daysUntil,
"DaysSinceExpiry": daysSince,
"LatestReminderSent": formatDate(q.GetReminderSent()),
"LatestReminderType": reminderTypeString(int(q.GetReminderType())),
}
}
type QuoteQueries interface {
GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error)
InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error)
GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error)
GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error)
GetExpiringSoonQuotesOnDay(ctx context.Context, arg db.GetExpiringSoonQuotesOnDayParams) ([]db.GetExpiringSoonQuotesOnDayRow, error)
GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error)
DisableQuoteReminders(ctx context.Context, params db.DisableQuoteRemindersParams) (sql.Result, error)
EnableQuoteReminders(ctx context.Context, id int32) (sql.Result, error)
}
type EmailSender interface {
SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error
SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error
}
type QuotesHandler struct {
queries QuoteQueries
tmpl *templates.TemplateManager
emailService EmailSender
}
func NewQuotesHandler(queries QuoteQueries, tmpl *templates.TemplateManager, emailService EmailSender) *QuotesHandler {
return &QuotesHandler{
queries: queries,
tmpl: tmpl,
emailService: emailService,
}
}
func (h *QuotesHandler) QuotesOutstandingView(w http.ResponseWriter, r *http.Request) {
// Days to look ahead and behind for expiring quotes
days := 7
// Show all quotes that are expiring in the next 7 days
expiringSoonQuotes, err := h.GetOutstandingQuotes(r, days)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Show all quotes that have expired in the last 60 days
recentlyExpiredQuotes, err := h.GetOutstandingQuotes(r, -60)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"RecentlyExpiredQuotes": recentlyExpiredQuotes,
"ExpiringSoonQuotes": expiringSoonQuotes,
"User": getUsername(r),
}
if err := h.tmpl.Render(w, "quotes/index.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// GetOutstandingQuotes returns outstanding quotes based on daysUntilExpiry.
func (h *QuotesHandler) GetOutstandingQuotes(r *http.Request, daysUntilExpiry int) ([]map[string]interface{}, error) {
var rows []map[string]interface{}
ctx := r.Context()
// If daysUntilExpiry is positive, get quotes expiring soon; if negative, get recently expired quotes
if daysUntilExpiry >= 0 {
quotes, err := h.queries.GetExpiringSoonQuotes(ctx, daysUntilExpiry)
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteRowWrapper{q}))
}
} else {
days := -daysUntilExpiry
quotes, err := h.queries.GetRecentlyExpiredQuotes(ctx, days)
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteRowWrapper{q}))
}
}
return rows, nil
}
// GetOutstandingQuotesOnDay returns quotes expiring exactly N days from today (if day >= 0), or exactly N days ago (if day < 0).
func (h *QuotesHandler) GetOutstandingQuotesOnDay(r *http.Request, day int) ([]map[string]interface{}, error) {
var rows []map[string]interface{}
ctx := r.Context()
// If day is positive, get quotes expiring on that day; if negative, get recently expired quotes on that day in the past
if day >= 0 {
quotes, err := h.queries.GetExpiringSoonQuotesOnDay(ctx, db.GetExpiringSoonQuotesOnDayParams{
DATEADD: day,
DATEADD_2: day,
})
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(ExpiringSoonQuoteOnDayRowWrapper{q}))
}
} else {
days := -day
quotes, err := h.queries.GetRecentlyExpiredQuotesOnDay(ctx, days)
if err != nil {
return nil, err
}
for _, q := range quotes {
rows = append(rows, formatQuoteRow(RecentlyExpiredQuoteOnDayRowWrapper{q}))
}
}
return rows, nil
}
type QuoteReminderType int
const (
FirstReminder QuoteReminderType = 1
SecondReminder QuoteReminderType = 2
ThirdReminder QuoteReminderType = 3
)
func (t QuoteReminderType) String() string {
switch t {
case FirstReminder:
return "FirstReminder"
case SecondReminder:
return "SecondReminder"
case ThirdReminder:
return "ThirdReminder"
default:
return "UnknownReminder"
}
}
type quoteReminderJob struct {
DayOffset int
ReminderType QuoteReminderType
}
// getReminderDetails returns the subject and template for a given reminder type
func getReminderDetails(reminderType QuoteReminderType) (subject, template string) {
switch reminderType {
case FirstReminder:
return "Reminder: Quote %s Expires Soon", "first_reminder.html"
case SecondReminder:
return "Follow-Up: Quote %s Expired", "second_reminder.html"
case ThirdReminder:
return "Final Reminder: Quote %s Closed", "final_reminder.html"
default:
return "", ""
}
}
// getReminderDetailsManual returns the subject and template for a manually sent reminder (generic templates without time references)
func getReminderDetailsManual(reminderType QuoteReminderType) (subject, template string) {
switch reminderType {
case FirstReminder:
return "Reminder: Quote %s Expires Soon", "manual_first_reminder.html"
case SecondReminder:
return "Follow-Up: Quote %s Expired", "manual_second_reminder.html"
case ThirdReminder:
return "Final Reminder: Quote %s Closed", "manual_final_reminder.html"
default:
return "", ""
}
}
// formatQuoteDates formats ISO date strings to DD/MM/YYYY format
func formatQuoteDates(dateIssuedStr, validUntilStr string) (submissionDate, expiryDate string) {
if dateIssuedStr != "" {
if t, err := time.Parse(time.RFC3339, dateIssuedStr); err == nil {
submissionDate = t.Format("02/01/2006")
} else {
submissionDate = dateIssuedStr
}
}
if validUntilStr != "" {
if t, err := time.Parse(time.RFC3339, validUntilStr); err == nil {
expiryDate = t.Format("02/01/2006")
} else {
expiryDate = validUntilStr
}
}
return
}
// prepareReminderEmail prepares all data needed to send a reminder email
func prepareReminderEmail(reminderType QuoteReminderType, customerName, dateIssuedStr, validUntilStr, enquiryRef, userEmail string) (subject, template string, templateData map[string]interface{}, ccs []string) {
subject, template = getReminderDetails(reminderType)
subject = fmt.Sprintf(subject, enquiryRef)
submissionDate, expiryDate := formatQuoteDates(dateIssuedStr, validUntilStr)
templateData = map[string]interface{}{
"CustomerName": customerName,
"SubmissionDate": submissionDate,
"ExpiryDate": expiryDate,
"QuoteRef": enquiryRef,
}
ccs = []string{"sales@cmctechnologies.com.au"}
if userEmail != "" {
ccs = append(ccs, userEmail)
}
return
}
// DailyQuoteExpirationCheck checks quotes for reminders and expiry notices (callable as a cron job from main)
func (h *QuotesHandler) DailyQuoteExpirationCheck() {
fmt.Println("Running DailyQuoteExpirationCheck...")
reminderJobs := []quoteReminderJob{
{7, FirstReminder},
{-7, SecondReminder},
{-60, ThirdReminder},
}
for _, job := range reminderJobs {
quotes, err := h.GetOutstandingQuotesOnDay((&http.Request{}), job.DayOffset)
if err != nil {
fmt.Printf("Error getting quotes for day offset %d: %v\n", job.DayOffset, err)
continue
}
if len(quotes) == 0 {
continue
}
for _, q := range quotes {
subject, template, templateData, ccs := prepareReminderEmail(
job.ReminderType,
q["CustomerName"].(string),
q["DateIssued"].(string),
q["ValidUntil"].(string),
q["EnquiryRef"].(string),
q["UserEmail"].(string),
)
// Construct PDF path from filesystem
enquiryRef := q["EnquiryRef"].(string)
pdfPath := fmt.Sprintf("/root/webroot/pdf/%s.pdf", enquiryRef)
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
err := h.SendQuoteReminderEmailWithPDF(
context.Background(),
q["ID"].(int32),
job.ReminderType,
q["CustomerEmail"].(string),
subject,
template,
templateData,
ccs,
nil,
pdfPath,
pdfFilename,
)
if err != nil {
fmt.Printf("Error sending %s for quote %v: %v\n", job.ReminderType.String(), q["ID"], err)
} else {
fmt.Printf("%s sent and recorded for quote %v\n", job.ReminderType.String(), q["ID"])
}
}
}
}
// SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it.
func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, allowDuplicate bool) error {
// Safeguard: check for valid recipient
if strings.TrimSpace(recipient) == "" {
return fmt.Errorf("recipient email is required")
}
// Safeguard: check for valid template data
if templateData == nil {
return fmt.Errorf("template data is required")
}
// Safeguard: check for valid reminder type
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
return fmt.Errorf("invalid reminder type: %v", reminderType)
}
// Check if reminder already sent (only if duplicates not allowed)
if !allowDuplicate {
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
})
if err != nil {
return fmt.Errorf("failed to check existing reminders: %w", err)
}
// Exit if the email has already been sent
if len(reminders) > 0 {
return nil
}
}
// Send the email
err := h.emailService.SendTemplateEmail(
recipient,
subject,
templateName,
templateData,
ccs, nil,
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
// Record the reminder
var user sql.NullString
if username != nil {
user = sql.NullString{String: *username, Valid: true}
} else {
user = sql.NullString{Valid: false}
}
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
DateSent: time.Now().UTC(),
Username: user,
})
if err != nil {
return fmt.Errorf("failed to record reminder: %w", err)
}
return nil
}
// SendQuoteReminderEmailWithPDF sends a reminder email with PDF attachment loaded from filesystem
func (h *QuotesHandler) SendQuoteReminderEmailWithPDF(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, pdfPath string, pdfFilename string) error {
// Safeguard: check for valid recipient
if strings.TrimSpace(recipient) == "" {
return fmt.Errorf("recipient email is required")
}
// Safeguard: check for valid template data
if templateData == nil {
return fmt.Errorf("template data is required")
}
// Safeguard: check for valid reminder type
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
return fmt.Errorf("invalid reminder type: %v", reminderType)
}
// Check if reminder already sent
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
})
if err != nil {
return fmt.Errorf("failed to check existing reminders: %w", err)
}
// Exit if the email has already been sent
if len(reminders) > 0 {
return nil
}
// Prepare PDF attachment if file exists
var attachments []interface{}
if _, err := os.Stat(pdfPath); err == nil {
attachments = []interface{}{
struct {
Filename string
FilePath string
}{
Filename: pdfFilename,
FilePath: pdfPath,
},
}
log.Printf("Attaching PDF for quote %d: %s", quoteID, pdfPath)
} else {
log.Printf("PDF not found for quote %d at %s, sending without attachment", quoteID, pdfPath)
}
// Send the email (with or without attachment)
err = h.emailService.SendTemplateEmailWithAttachments(
recipient,
subject,
templateName,
templateData,
ccs,
nil,
attachments,
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
// Record the reminder
var user sql.NullString
if username != nil {
user = sql.NullString{String: *username, Valid: true}
} else {
user = sql.NullString{Valid: false}
}
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
DateSent: time.Now().UTC(),
Username: user,
})
if err != nil {
return fmt.Errorf("failed to record reminder: %w", err)
}
return nil
}
// SendManualReminder handles POST requests to manually send a quote reminder
func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
// Get parameters
quoteIDStr := r.FormValue("quote_id")
reminderTypeStr := r.FormValue("reminder_type")
customerEmail := r.FormValue("customer_email")
userEmail := r.FormValue("user_email")
enquiryRef := r.FormValue("enquiry_ref")
customerName := r.FormValue("customer_name")
dateIssuedStr := r.FormValue("date_issued")
validUntilStr := r.FormValue("valid_until")
// Validate required fields
if quoteIDStr == "" || reminderTypeStr == "" || customerEmail == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
if err != nil {
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
return
}
reminderTypeInt, err := strconv.Atoi(reminderTypeStr)
if err != nil {
http.Error(w, "Invalid reminder type", http.StatusBadRequest)
return
}
reminderType := QuoteReminderType(reminderTypeInt)
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
http.Error(w, "Invalid reminder type value", http.StatusBadRequest)
return
}
// Use manual template for manually sent reminders (generic without time references)
subject, template := getReminderDetailsManual(reminderType)
subject = fmt.Sprintf(subject, enquiryRef)
submissionDate, expiryDate := formatQuoteDates(dateIssuedStr, validUntilStr)
templateData := map[string]interface{}{
"CustomerName": customerName,
"SubmissionDate": submissionDate,
"ExpiryDate": expiryDate,
"QuoteRef": enquiryRef,
}
ccs := []string{"sales@cmctechnologies.com.au"}
if userEmail != "" {
ccs = append(ccs, userEmail)
}
// Attach PDF quote from filesystem
pdfPath := fmt.Sprintf("/root/webroot/pdf/%s.pdf", enquiryRef)
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
// Get username from request
username := getUsername(r)
usernamePtr := &username
// Send the reminder with attachment
err = h.SendQuoteReminderEmailWithPDF(
r.Context(),
int32(quoteID),
reminderType,
customerEmail,
subject,
template,
templateData,
ccs,
usernamePtr,
pdfPath,
pdfFilename,
)
if err != nil {
log.Printf("Failed to send manual reminder for quote %d: %v", quoteID, err)
http.Error(w, fmt.Sprintf("Failed to send reminder: %v", err), http.StatusInternalServerError)
return
}
// Check if this is an AJAX request
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Reminder sent successfully",
})
return
}
// Redirect back to quotes page for non-AJAX requests
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
}
// DisableReminders handles POST requests to disable automatic reminders for a quote
func (h *QuotesHandler) DisableReminders(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
quoteIDStr := r.FormValue("quote_id")
if quoteIDStr == "" {
http.Error(w, "Missing quote_id", http.StatusBadRequest)
return
}
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
if err != nil {
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
return
}
// Get username from request
username := getUsername(r)
// Update the database to disable reminders
_, err = h.queries.DisableQuoteReminders(r.Context(), db.DisableQuoteRemindersParams{
RemindersDisabledBy: sql.NullString{String: username, Valid: true},
ID: int32(quoteID),
})
if err != nil {
log.Printf("Failed to disable reminders for quote %d: %v", quoteID, err)
http.Error(w, fmt.Sprintf("Failed to disable reminders: %v", err), http.StatusInternalServerError)
return
}
// Check if this is an AJAX request
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Reminders disabled successfully",
})
return
}
// Redirect back to quotes page for non-AJAX requests
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
}
// EnableReminders handles POST requests to re-enable automatic reminders for a quote
func (h *QuotesHandler) EnableReminders(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
quoteIDStr := r.FormValue("quote_id")
if quoteIDStr == "" {
http.Error(w, "Missing quote_id", http.StatusBadRequest)
return
}
quoteID, err := strconv.ParseInt(quoteIDStr, 10, 32)
if err != nil {
http.Error(w, "Invalid quote ID", http.StatusBadRequest)
return
}
// Update the database to enable reminders
_, err = h.queries.EnableQuoteReminders(r.Context(), int32(quoteID))
if err != nil {
log.Printf("Failed to enable reminders for quote %d: %v", quoteID, err)
http.Error(w, fmt.Sprintf("Failed to enable reminders: %v", err), http.StatusInternalServerError)
return
}
// Check if this is an AJAX request
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Reminders enabled successfully",
})
return
}
// Redirect back to quotes page for non-AJAX requests
http.Redirect(w, r, "/go/quotes", http.StatusSeeOther)
}
// Helper: get reminder type as string
func reminderTypeString(reminderType int) string {
switch reminderType {
case 0:
return "No Reminder"
case 1:
return "First Reminder"
case 2:
return "Second Reminder"
case 3:
return "Final Reminder"
default:
return ""
}
}

View file

@ -0,0 +1,359 @@
package handlers
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"code.springupsoftware.com/cmc/cmc-sales/internal/cmc/db"
)
// Mocks
type mockQuoteRow struct {
reminderType int32
reminderSent time.Time
designatedDay time.Weekday
emailSent bool
}
func (m *mockQuoteRow) GetReminderType() int32 { return m.reminderType }
func (m *mockQuoteRow) GetReminderSent() time.Time { return m.reminderSent }
func (m *mockQuoteRow) GetCustomerEmail() string { return "test@example.com" }
// Realistic mock for db.Queries
type mockQueries struct {
reminders []db.QuoteReminder
insertCalled bool
}
func (m *mockQueries) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
var filtered []db.QuoteReminder
for _, r := range m.reminders {
if r.QuoteID == params.QuoteID && r.ReminderType == params.ReminderType {
filtered = append(filtered, r)
}
}
return filtered, nil
}
func (m *mockQueries) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
m.insertCalled = true
m.reminders = append(m.reminders, db.QuoteReminder{
QuoteID: params.QuoteID,
ReminderType: params.ReminderType,
DateSent: params.DateSent,
Username: params.Username,
})
return &mockResult{}, nil
}
func (m *mockQueries) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
return nil, nil
}
func (m *mockQueries) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
return nil, nil
}
func (m *mockQueries) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
return nil, nil
}
func (m *mockQueries) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
return nil, nil
}
// Mock sql.Result for InsertQuoteReminder
type mockResult struct{}
func (m *mockResult) LastInsertId() (int64, error) { return 1, nil }
func (m *mockResult) RowsAffected() (int64, error) { return 1, nil }
// Realistic mock for email.EmailService
type mockEmailService struct {
sent bool
sentReminders map[int32]map[int32]bool // quoteID -> reminderType -> sent
}
func (m *mockEmailService) SendTemplateEmail(to, subject, templateName string, data interface{}, ccs, bccs []string) error {
m.sent = true
if m.sentReminders == nil {
m.sentReminders = make(map[int32]map[int32]bool)
}
var quoteID int32
var reminderType int32
if dataMap, ok := data.(map[string]interface{}); ok {
if id, ok := dataMap["QuoteID"].(int32); ok {
quoteID = id
} else if id, ok := dataMap["QuoteID"].(int); ok {
quoteID = int32(id)
}
if rt, ok := dataMap["ReminderType"].(int32); ok {
reminderType = rt
} else if rt, ok := dataMap["ReminderType"].(int); ok {
reminderType = int32(rt)
}
}
if quoteID == 0 {
quoteID = 123
}
if reminderType == 0 {
reminderType = 1
}
if m.sentReminders[quoteID] == nil {
m.sentReminders[quoteID] = make(map[int32]bool)
}
m.sentReminders[quoteID][reminderType] = true
return nil
}
// Mock for db.Queries with error simulation
type mockQueriesError struct{}
func (m *mockQueriesError) GetQuoteRemindersByType(ctx context.Context, params db.GetQuoteRemindersByTypeParams) ([]db.QuoteReminder, error) {
return nil, errors.New("db error")
}
func (m *mockQueriesError) InsertQuoteReminder(ctx context.Context, params db.InsertQuoteReminderParams) (sql.Result, error) {
return &mockResult{}, nil
}
func (m *mockQueriesError) GetExpiringSoonQuotes(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetRecentlyExpiredQuotes(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetExpiringSoonQuotesOnDay(ctx context.Context, dateADD interface{}) ([]db.GetExpiringSoonQuotesOnDayRow, error) {
return nil, nil
}
func (m *mockQueriesError) GetRecentlyExpiredQuotesOnDay(ctx context.Context, dateSUB interface{}) ([]db.GetRecentlyExpiredQuotesOnDayRow, error) {
return nil, nil
}
// Test: Should send email on designated day if not already sent
// Description: Verifies that a reminder email is sent and recorded when no previous reminder exists for the quote. Should pass unless the handler logic is broken.
func TestSendQuoteReminderEmail_OnDesignatedDay(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
// Simulate designated day logic by calling SendQuoteReminderEmail
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !me.sent {
t.Error("Expected email to be sent on designated day")
}
if !mq.insertCalled {
t.Error("Expected reminder to be recorded on designated day")
}
}
// Test: Should NOT send email if reminder already sent
// Description: Verifies that if a reminder for the quote and type already exists, the handler does not send another email or record a duplicate. Should fail if duplicate reminders are allowed.
func TestSendQuoteReminderEmail_AlreadyReminded(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err == nil {
t.Error("Expected error for already sent reminder")
}
if me.sent {
t.Error("Expected email NOT to be sent if already reminded")
}
if mq.insertCalled {
t.Error("Expected reminder NOT to be recorded if already reminded")
}
}
// Test: Should only send reminder once, second call should fail
// Description: Sends a reminder, then tries to send the same reminder again. The first should succeed, the second should fail and not send or record a duplicate. Should fail if duplicates are allowed.
func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
// First call should succeed
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err1 != nil {
t.Fatalf("Expected first call to succeed, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders))
}
// Second call should fail
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err2 == nil {
t.Error("Expected error for already sent reminder on second call")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected no additional reminder recorded in DB, got %d", len(mq.reminders))
}
}
// Test: Should send and record reminder if not already sent
// Description: Verifies that a reminder is sent and recorded when no previous reminder exists. Should pass unless the handler logic is broken.
func TestSendQuoteReminderEmail_SendsIfNotAlreadySent(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if !me.sent {
t.Error("Expected email to be sent")
}
if !mq.insertCalled {
t.Error("Expected reminder to be recorded")
}
}
// Test: Should ignore already sent reminder
// Description: Verifies that the handler does not send or record a reminder if one already exists for the quote/type. Should fail if duplicates are allowed.
func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{{QuoteID: 123, ReminderType: 1}}}
me := &mockEmailService{}
h := &QuotesHandler{
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err == nil {
t.Error("Expected error for already sent reminder")
}
if me.sent {
t.Error("Expected email NOT to be sent")
}
if mq.insertCalled {
t.Error("Expected reminder NOT to be recorded")
}
}
// Test: Should fail if DB returns error
// Description: Simulates a DB error and expects the handler to return an error. Should fail if DB errors are not handled.
func TestSendQuoteReminderEmail_DBError(t *testing.T) {
h := &QuotesHandler{queries: &mockQueriesError{}}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil {
t.Error("Expected error when DB fails, got nil")
}
}
// Edge case: nil recipient
// Test: Should fail if recipient is empty
// Description: Verifies that the handler returns an error if the recipient email is missing. Should fail if emails are sent to empty recipients.
func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil {
t.Error("Expected error for nil recipient, got nil")
}
}
// Edge case: missing template data
// Test: Should fail if template data is missing
// Description: Verifies that the handler returns an error if template data is nil. Should fail if emails are sent without template data.
func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil, nil, false)
if err == nil {
t.Error("Expected error for missing template data, got nil")
}
}
// Boundary: invalid reminder type
// Test: Should fail if reminder type is invalid
// Description: Verifies that the handler returns an error for an invalid reminder type. Should fail if invalid types are allowed.
func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil {
t.Error("Expected error for invalid reminder type, got nil")
}
}
// Test: Multiple reminders of different types allowed for same quote
// Description: Verifies that reminders of different types for the same quote can be sent and recorded independently. Should fail if only one reminder per quote is allowed.
func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{queries: mq, emailService: me}
// First reminder type
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err1 != nil {
t.Fatalf("Expected first reminder to be sent, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
}
// Second reminder type
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 2, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 2}, nil, nil, false)
if err2 != nil {
t.Fatalf("Expected second reminder to be sent, got %v", err2)
}
if !me.sentReminders[123][2] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 2")
}
if len(mq.reminders) != 2 {
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
}
}
// Test: Reminders for different quotes are independent
// Description: Verifies that reminders for different quotes do not block each other and are recorded independently. Should fail if reminders for one quote affect another.
func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
me := &mockEmailService{}
h := &QuotesHandler{queries: mq, emailService: me}
// Send reminder for quote 123
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err1 != nil {
t.Fatalf("Expected reminder for quote 123 to be sent, got %v", err1)
}
if !me.sentReminders[123][1] {
t.Error("Expected email to be sent and recorded for quote 123, reminder 1")
}
if len(mq.reminders) != 1 {
t.Errorf("Expected 1 reminder recorded in DB after first send, got %d", len(mq.reminders))
}
// Send reminder for quote 456
err2 := h.SendQuoteReminderEmail(context.Background(), 456, 1, "test2@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 456, "ReminderType": 1}, nil, nil, false)
if err2 != nil {
t.Fatalf("Expected reminder for quote 456 to be sent, got %v", err2)
}
if !me.sentReminders[456][1] {
t.Error("Expected email to be sent and recorded for quote 456, reminder 1")
}
if len(mq.reminders) != 2 {
t.Errorf("Expected 2 reminders recorded in DB after both sends, got %d", len(mq.reminders))
}
}

Some files were not shown because too many files have changed in this diff Show more