v0.3.0; add med compliance module

This commit is contained in:
ゆめ 2022-11-16 14:54:46 -06:00
parent 6abccc5429
commit eca61144e5
20 changed files with 1545 additions and 21 deletions

View file

@ -8,6 +8,7 @@ WorkingDirectory=/var/lib/caddy/yoake
ExecStart=/var/lib/caddy/yoake/server
Restart=always
AmbientCapabilities=CAP_NET_BIND_SERVICE
AppArmorProfile=yoake
[Install]
WantedBy=multi-user.target

42
etc/yoake.apparmor Normal file
View file

@ -0,0 +1,42 @@
# Last Modified: Fri Nov 11 17:59:18 2022
abi <abi/3.0>,
include <tunables/global>
@{YOAKE}="/var/lib/caddy/yoake"
@{YOAKE_DATA}="/var/lib/caddy/yoake.badger"
profile yoake @{YOAKE}/server {
include <abstractions/base>
include <abstractions/nameservice>
include <abstractions/apparmor_api/introspect>
^ssl {
include <abstractions/base>
include <abstractions/ssl_certs>
include <abstractions/ssl_keys>
include <abstractions/apparmor_api/introspect>
}
/sys/kernel/mm/transparent_hugepage/* r,
/proc/sys/net/core/somaxconn r,
@{YOAKE}/server mr,
@{YOAKE}/** r,
@{YOAKE_DATA}/ rwk,
@{YOAKE_DATA}/** rwlk,
capability net_bind_service,
signal send peer=yoake//serve,
^serve flags=(kill) {
include <abstractions/base>
include <abstractions/nameservice>
include <abstractions/apparmor_api/introspect>
/sys/kernel/mm/transparent_hugepage/ r,
@{YOAKE}/server mr,
@{YOAKE}/ r,
@{YOAKE}/{assets,webroot}/** r,
@{YOAKE_DATA}/ rwk,
@{YOAKE_DATA}/** rwlk,
signal receive peer=yoake,
}
}

11
go.mod
View file

@ -6,12 +6,16 @@ require (
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible
github.com/PuerkitoBio/goquery v1.8.0
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/dgraph-io/badger/v3 v3.2103.4
github.com/eternal-flame-AD/go-apparmor v0.0.3
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c
github.com/google/uuid v1.3.0
github.com/gorilla/context v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/jinzhu/configor v1.2.1
github.com/labstack/echo/v4 v4.9.1
github.com/stretchr/testify v1.8.1
github.com/twilio/twilio-go v1.1.1
github.com/vanng822/go-premailer v1.20.1
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
@ -22,11 +26,9 @@ require (
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/dgraph-io/badger/v3 v3.2103.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/duo-labs/webauthn v0.0.0-20220815211337-00c9fb5711f5 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/eternal-flame-AD/go-apparmor v0.0.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
@ -43,7 +45,7 @@ require (
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/testify v1.8.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/vanng822/css v1.0.1 // indirect
@ -56,4 +58,5 @@ require (
google.golang.org/protobuf v1.26.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

20
go.sum
View file

@ -3,6 +3,7 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
@ -31,16 +32,11 @@ github.com/dgraph-io/badger/v3 v3.2103.4 h1:WE1B07YNTTJTtG9xjBcSW2wn0RJLyiV99h95
github.com/dgraph-io/badger/v3 v3.2103.4/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/duo-labs/webauthn v0.0.0-20220815211337-00c9fb5711f5 h1:BaeJtFDlto/NjX9t730OebRRJf2P+t9YEDz3ur18824=
github.com/duo-labs/webauthn v0.0.0-20220815211337-00c9fb5711f5/go.mod h1:Jcj7rFNlTknb18v9jpSA58BveX2LDhXqaoy+6YV1N9g=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/eternal-flame-AD/go-apparmor v0.0.1 h1:LBWfkf/Mx0s6inwqurWC8nME7ICg4cDmh2fmOkBeenI=
github.com/eternal-flame-AD/go-apparmor v0.0.1/go.mod h1:K8VSDcvYN18uG+vsnR+3um4t6fX13Km6ci9mgQfDMg8=
github.com/eternal-flame-AD/go-apparmor v0.0.2 h1:sjDN6pyyjXBB+o+oDt6kyo2xiE8vvjZRABZ0fJEzHiE=
github.com/eternal-flame-AD/go-apparmor v0.0.2/go.mod h1:OpqESxf/LXsssooWBPzAoIAC2PtloCT1CmA+glQKYV8=
github.com/eternal-flame-AD/go-apparmor v0.0.3 h1:nFOxi6mbE8wpd5aHbSGvjbnaEjYC156IICWTteSgEIo=
github.com/eternal-flame-AD/go-apparmor v0.0.3/go.mod h1:OpqESxf/LXsssooWBPzAoIAC2PtloCT1CmA+glQKYV8=
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1 h1:B+ad4UMWwNAUsZhLLQCCrEx+cfLsbf0+AbbcfG7RIv0=
@ -52,7 +48,6 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -60,7 +55,6 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
@ -73,7 +67,10 @@ github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
@ -92,6 +89,7 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
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=
@ -110,7 +108,6 @@ github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -119,6 +116,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@ -230,6 +228,7 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -244,11 +243,10 @@ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gG
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View file

@ -229,7 +229,7 @@ func registerYoutube(g *echo.Group, database db.DB) {
defer txn.txn.Discard()
categories, err := txn.GetCategories()
if err != nil {
if err != nil && !db.IsNotFound(err) {
return err
}
return c.JSON(http.StatusOK, categories)

44
internal/health/api.go Normal file
View file

@ -0,0 +1,44 @@
package health
import (
"sync"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/comm"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/labstack/echo/v4"
)
func Register(g *echo.Group, db db.DB, comm *comm.Communicator) {
megsG := g.Group("/meds")
{
shortHands := megsG.Group("/shorthand")
{
shortHands.GET("/parse", RESTParseShorthand())
shortHands.POST("/parse", RESTParseShorthand())
shortHands.POST("/format", RESTFormatShorthand())
}
writeMutex := new(sync.Mutex)
directions := megsG.Group("/directions", auth.RequireMiddleware(auth.RoleAdmin))
{
directions.GET("", RESTMedGetDirections(db))
directions.POST("", RESTMedPostDirections(db, writeMutex))
directions.DELETE("/:name", RESTMedDeleteDirections(db, writeMutex))
}
compliance := megsG.Group("/compliance", auth.RequireMiddleware(auth.RoleAdmin))
{
complianceByMed := compliance.Group("/med/:med")
{
complianceByMed.GET("/log", RESTComplianceLogGet(db))
complianceByMed.GET("/project", RESTComplianceLogProjectMed(db))
}
compliance.GET("/log", RESTComplianceLogGet(db))
compliance.POST("/log", RESTComplianceLogPost(db, writeMutex))
}
}
}

View file

@ -0,0 +1,126 @@
package health
import (
"fmt"
"sync"
"time"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/labstack/echo/v4"
)
func RESTComplianceLogGet(database db.DB) func(c echo.Context) error {
return func(c echo.Context) error {
filterKeyname := c.Param("med")
from := c.QueryParam("from")
to := c.QueryParam("to")
if to == "" {
to = time.Now().Format("2006-01-02")
}
if from == "" {
from = time.Now().AddDate(0, 0, -30).Format("2006-01-02")
}
fromTime, err := time.Parse("2006-01-02", from)
if err != nil {
return echoerror.NewHttp(400, err)
}
toTime, err := time.Parse("2006-01-02", to)
if err != nil {
return echoerror.NewHttp(400, err)
}
period := util.NewDateRange(fromTime, toTime)
if days := period.Days(); days > 180 {
return echoerror.NewHttp(400, fmt.Errorf("invalid date range: %v", period))
}
logs, err := DBMedComplianceLogGet(database, period)
if db.IsNotFound(err) || logs == nil {
return c.JSON(200, ComplianceLogList{})
} else if err != nil {
return echoerror.NewHttp(500, err)
}
if filterKeyname != "" {
filtered := make(ComplianceLogList, 0, len(logs))
for _, log := range logs {
if log.MedKeyname == filterKeyname {
filtered = append(filtered, log)
}
}
logs = filtered
}
return c.JSON(200, logs)
}
}
func RESTComplianceLogPost(db db.DB, writeMutex *sync.Mutex) echo.HandlerFunc {
return func(c echo.Context) error {
var input ComplianceLog
if err := c.Bind(&input); err != nil {
return echoerror.NewHttp(400, err)
}
if input.Actual.Time.IsZero() {
return echoerror.NewHttp(400, fmt.Errorf("invalid date"))
}
writeMutex.Lock()
defer writeMutex.Unlock()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
var dir *Direction
for _, med := range meds {
d := med
if med.KeyName() == input.MedKeyname {
dir = &d
} else if med.Name == input.MedKeyname {
input.MedKeyname = med.KeyName()
dir = &d
}
}
if dir == nil {
return echoerror.NewHttp(404, fmt.Errorf("med not found"))
}
if err := DBMedComplianceLogSetOne(db, *dir, &input); err != nil {
return err
}
if input.Actual.Dose <= 0 {
return c.NoContent(204)
}
return c.JSON(200, input)
}
}
func RESTComplianceLogProjectMed(db db.DB) func(c echo.Context) error {
return func(c echo.Context) error {
keyName := c.Param("med")
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
var dir *Direction
for _, med := range meds {
if med.KeyName() == keyName {
d := med
dir = &d
}
}
if dir == nil {
return echoerror.NewHttp(404, fmt.Errorf("med not found"))
}
complianceLog, err := DBMedComplianceLogGet(db, util.DateRangeAround(time.Now(), 1))
if err != nil {
return echoerror.NewHttp(500, err)
}
return c.JSON(200, complianceLog.ProjectNextDose(*dir))
}
}

View file

@ -0,0 +1,92 @@
package health
import (
"fmt"
"net/http"
"strings"
"sync"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
)
func RESTMedGetDirections(db db.DB) func(c echo.Context) error {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
c.Error(echoerror.NewHttp(500, fmt.Errorf("internal error: %v", err)))
}
}()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
return c.JSON(200, meds)
}
}
func RESTMedPostDirections(db db.DB, writeMutex *sync.Mutex) func(c echo.Context) error {
return func(c echo.Context) error {
var input Direction
if err := c.Bind(&input); err != nil {
return echoerror.NewHttp(400, err)
}
if input.Name == "" {
return echoerror.NewHttp(400, fmt.Errorf("name cannot be empty"))
}
if input.Dosage <= 0 {
return echoerror.NewHttp(400, fmt.Errorf("dosage must be positive"))
}
if input.PeriodHours <= 0 {
return echoerror.NewHttp(400, fmt.Errorf("period must be positive"))
}
writeMutex.Lock()
defer writeMutex.Unlock()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
found := false
for i, med := range meds {
if med.KeyName() == input.KeyName() {
meds[i] = input
found = true
}
}
if !found {
meds = append(meds, input)
}
if err := DBMedListSet(db, meds); err != nil {
return echoerror.NewHttp(500, err)
}
return c.JSON(200, meds)
}
}
func RESTMedDeleteDirections(db db.DB, writeMutex *sync.Mutex) func(c echo.Context) error {
return func(c echo.Context) error {
name := c.Param("name")
writeMutex.Lock()
defer writeMutex.Unlock()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
found := false
for i, med := range meds {
if strings.EqualFold(med.KeyName(), name) {
meds = append(meds[:i], meds[i+1:]...)
found = true
break
}
}
if !found {
return echoerror.NewHttp(404, fmt.Errorf("med not found"))
}
if err := DBMedListSet(db, meds); err != nil {
return echoerror.NewHttp(500, err)
}
return c.NoContent(http.StatusNoContent)
}
}

View file

@ -0,0 +1,53 @@
package health
import (
"fmt"
"strings"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
)
func RESTParseShorthand() func(c echo.Context) error {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
c.Error(echoerror.NewHttp(500, fmt.Errorf("internal error: %v", err)))
}
}()
var inputStr string
if c.Request().Method == "GET" {
inputStr = c.QueryParam("shorthand")
} else if c.Request().Method == "POST" {
inputStr = c.FormValue("shorthand")
} else {
return echoerror.NewHttp(405, fmt.Errorf("unsupported method"))
}
inputStr = strings.TrimSpace(inputStr)
parsed, err := ParseShorthand(inputStr)
if err != nil {
return echoerror.NewHttp(400, err)
}
return c.JSON(200, parsed)
}
}
func RESTFormatShorthand() func(c echo.Context) error {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
c.Error(echoerror.NewHttp(500, fmt.Errorf("internal error: %v", err)))
}
}()
var input Direction
if err := c.Bind(&input); err != nil {
return echoerror.NewHttp(400, err)
}
name, formatted := input.ShortHand()
return c.JSON(200, map[string]string{
"name": name,
"direction": formatted,
"__disclaimer": DirectionDisclaimer,
})
}
}

View file

@ -0,0 +1,152 @@
package health
import (
"math"
"sort"
"time"
"github.com/eternal-flame-AD/yoake/internal/util"
)
type ComplianceLog struct {
UUID string `json:"uuid,omitempty"`
MedKeyname string `json:"med_keyname"`
Expected ComplianceDoseInfo `json:"expected"`
Actual ComplianceDoseInfo `json:"actual"`
// 0 = closest to expected time +1 = closest to next expected dose
// get a cumsum of this to get a compliance stat
DoseOffset float64 `json:"dose_offset"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceDoseInfo struct {
Time time.Time `json:"time"`
Dose int `json:"dose"`
}
type ComplianceLogList []ComplianceLog
func (c ComplianceLogList) ProjectNextDose(dir Direction) (nextDose ComplianceLog) {
sort.Sort(c)
var lastDose ComplianceLog
var cumDosage int
for ptr := 0; ptr < len(c); ptr++ {
if c[ptr].MedKeyname == dir.KeyName() {
if dir.OptSchedule == OptScheduleWholeDose {
lastDose = c[ptr]
break
} else {
cumDosage += c[ptr].Actual.Dose
if cumDosage < dir.Dosage {
continue
} else {
lastDose = c[ptr]
break
}
}
}
}
if lastDose.UUID == "" /* not found */ {
nextDose = ComplianceLog{
MedKeyname: dir.KeyName(),
Expected: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
Actual: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
DoseOffset: 0,
CreatedAt: time.Now(),
}
} else {
nextDose = ComplianceLog{
MedKeyname: dir.KeyName(),
Expected: ComplianceDoseInfo{
Time: lastDose.Actual.Time.Add(time.Duration(dir.PeriodHours) * time.Hour),
Dose: dir.Dosage,
},
Actual: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
CreatedAt: time.Now(),
}
nextDose.DoseOffset, _, _ = c.ComputeDoseOffset(dir, &nextDose)
}
return
}
func (c ComplianceLogList) ComputeDoseOffset(dir Direction, newLog *ComplianceLog) (float64, bool, error) {
sort.Sort(c)
var lastTwoDoses []ComplianceLog
if newLog != nil {
lastTwoDoses = []ComplianceLog{*newLog}
}
for ptr := 0; len(lastTwoDoses) < 2 && ptr < len(c); ptr++ {
if c[ptr].MedKeyname == dir.KeyName() {
if len(lastTwoDoses) == 0 || lastTwoDoses[0].Actual.Time.After(c[ptr].Actual.Time) {
lastTwoDoses = append(lastTwoDoses, c[ptr])
}
}
}
if newLog != nil {
if newLog.Expected.Dose == 0 && dir.KeyName() == newLog.MedKeyname {
newLog.Expected.Dose = dir.Dosage
}
if newLog.Expected.Time.IsZero() {
if len(lastTwoDoses) == 2 {
newLog.Expected.Time = lastTwoDoses[1].Actual.Time.Add(time.Duration(dir.PeriodHours) * time.Hour)
} else {
newLog.Expected.Time = newLog.Actual.Time
}
}
lastTwoDoses[0] = *newLog
}
if len(lastTwoDoses) < 2 {
return 0, false, nil
}
// now we have:
// *exp ~actual
// * ~ ~ *
// offset = (new_actual - last_expected) / diff(new_expected, last_actual) - 1
if lastTwoDoses[0].Actual.Time.IsZero() {
lastTwoDoses[0].Actual.Time = time.Now()
}
offset := float64(lastTwoDoses[0].Actual.Time.Sub(lastTwoDoses[1].Expected.Time))/
float64(lastTwoDoses[0].Expected.Time.Sub(lastTwoDoses[1].Actual.Time)) - 1
// for prn ignore positive offsets
if util.Contain(dir.Flags, DirectionFlagPRN) {
if offset > 0 {
offset = 0
}
}
if math.Abs(offset) > 2 {
// stop counting if three or more doses are missed
return 0, false, nil
}
return offset, true, nil
}
func (c ComplianceLogList) Len() int {
return len(c)
}
func (c ComplianceLogList) Less(i, j int) bool {
return c[i].Actual.Time.After(c[j].Actual.Time)
}
func (c ComplianceLogList) Swap(i, j int) {
c[i], c[j] = c[j], c[i]
}

164
internal/health/db.go Normal file
View file

@ -0,0 +1,164 @@
package health
import (
"errors"
"sort"
"time"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/google/uuid"
)
func DBMedListGet(database db.DB) ([]Direction, error) {
txn := database.NewTransaction(false)
defer txn.Discard()
var meds []Direction
if err := db.GetJSON(txn, []byte("health_meds_list"), &meds); db.IsNotFound(err) {
err = DBMedListSet(database, []Direction{})
return meds, err
} else if err != nil {
return nil, err
}
return meds, nil
}
func DBMedListSet(database db.DB, meds []Direction) error {
txn := database.NewTransaction(true)
defer txn.Discard()
for i, med := range meds {
_, meds[i].DirectionShorthand = med.ShortHand()
}
if err := db.SetJSON(txn, []byte("health_meds_list"), meds); err != nil {
return err
}
return txn.Commit()
}
const dbMedComplianceLogPrefix = "health_meds_compliance_log_"
func DBMedComplianceLogGet(database db.DB, dates util.DateRange) (ComplianceLogList, error) {
txn := database.NewTransaction(false)
defer txn.Discard()
endIndex := dates.To.UTC().Format("2006-01")
indexesToFetch := []string{endIndex}
for indexNow := dates.From.UTC().AddDate(0, -1, 0); indexNow.Before(dates.To.AddDate(0, 1, 0)); indexNow = indexNow.AddDate(0, 1, 0) {
indexesToFetch = append(indexesToFetch, indexNow.Format("2006-01"))
}
indexesToFetch = util.Unique(indexesToFetch)
sort.Strings(indexesToFetch)
var res ComplianceLogList
for _, index := range indexesToFetch {
var log []ComplianceLog
if err := db.GetJSON(txn, []byte(dbMedComplianceLogPrefix+index), &log); db.IsNotFound(err) {
continue
} else if err != nil {
return nil, err
}
res = append(res, log...)
}
sort.Sort(res)
return res, nil
}
func DBMedComplianceLogSetOne(database db.DB, dir Direction, log *ComplianceLog) error {
index := log.Actual.Time.UTC().Format("2006-01")
existingLogs, err := DBMedComplianceLogGet(database, util.DateRangeAround(log.Actual.Time, 1))
if err != nil {
return err
}
txn := database.NewTransaction(true)
defer txn.Discard()
del := false
if log.Actual.Dose == 0 {
return echoerror.NewHttp(400, errors.New("dose cannot be zero"))
} else if log.Actual.Dose < 0 {
del = true
}
if log.UUID != "" {
foundIdx := -1
for i, existingLog := range existingLogs {
if existingLog.UUID == log.UUID {
log.UpdatedAt = time.Now()
foundIdx = i
break
}
}
if foundIdx < 0 {
return echoerror.NewHttp(404, errors.New("log with specified UUID not found"))
}
origLog := existingLogs[foundIdx]
log.CreatedAt = origLog.CreatedAt
origLogIdx := origLog.Actual.Time.UTC().Format("2006-01")
if origLogIdx == index {
// update and return
if del {
existingLogs = append(existingLogs[:foundIdx], existingLogs[foundIdx+1:]...)
} else {
log.UpdatedAt = time.Now()
existingLogs[foundIdx] = *log
}
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), existingLogs); err != nil {
return err
}
return txn.Commit()
} else {
// delete from old index
existingLogs = append(existingLogs[:foundIdx], existingLogs[foundIdx+1:]...)
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+origLogIdx), existingLogs); err != nil {
return err
}
}
}
if del {
return txn.Commit()
}
if log.UUID == "" {
log.UUID = uuid.New().String()
log.CreatedAt = time.Now()
}
log.UpdatedAt = time.Now()
var logs ComplianceLogList
if err := db.GetJSON(txn, []byte(dbMedComplianceLogPrefix+index), &logs); db.IsNotFound(err) {
logs = []ComplianceLog{*log}
} else if err != nil {
return err
} else {
logs = append(logs, *log)
}
for i := len(logs) - 1; i >= 0; i-- {
offset, upd, err := logs.ComputeDoseOffset(dir, &logs[i])
if err != nil {
return err
}
if upd {
logs[i].DoseOffset = offset
}
}
sort.Sort(ComplianceLogList(logs))
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), logs); err != nil {
return err
}
*log = logs[len(logs)-1]
return txn.Commit()
}

230
internal/health/parser.go Normal file
View file

@ -0,0 +1,230 @@
package health
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/eternal-flame-AD/yoake/internal/util"
)
type Direction struct {
Name string `json:"name"`
PeriodHours int `json:"period_hours"`
Dosage int `json:"dosage"`
DosageUnit string `json:"dosage_unit"`
DosageRoute string `json:"dosage_route"`
Flags []DirectionFlag `json:"flags"`
DirectionShorthand string `json:"shorthand"`
OptSchedule OptSchedule `json:"schedule"`
Disclaimer string `json:"__disclaimer"`
}
const DirectionDisclaimer = "For personal use only. No warranty of accuracy."
type DirectionFlag string
type OptSchedule string
const (
DirectionFlagAM DirectionFlag = "qam"
DirectionFlagHS DirectionFlag = "qhs"
DirectionFlagPRN DirectionFlag = "prn"
OptScheduleDefault OptSchedule = "default"
OptScheduleWholeDose OptSchedule = "whole"
)
func ParseShorthand(shorthand string) (*Direction, error) {
res := new(Direction)
res.Disclaimer = DirectionDisclaimer
res.Flags = make([]DirectionFlag, 0)
words := strings.Split(shorthand, " ")
optionsRegex := regexp.MustCompile(`^([a-zA-Z]+)\((\w+)\)$`)
for i := len(words) - 1; i >= 0; i-- {
if match := optionsRegex.FindStringSubmatch(words[i]); match != nil {
name, value := match[1], match[2]
switch strings.ToLower(name) {
case "sched":
fallthrough
case "schedule":
if res.OptSchedule != "" {
return nil, fmt.Errorf("duplicate schedule option")
}
switch strings.ToLower(value) {
case "default":
fallthrough
case "":
res.OptSchedule = OptScheduleDefault
case "whole":
res.OptSchedule = OptScheduleWholeDose
default:
return nil, fmt.Errorf("invalid schedule option: %s", value)
}
default:
return nil, fmt.Errorf("unknown option %s", name)
}
words = words[:i]
}
}
if res.OptSchedule == "" {
res.OptSchedule = OptScheduleDefault
}
// combined numbers and units
for i := range words {
digits := regexp.MustCompile(`^\d+$`)
if digits.MatchString(words[i]) {
words[i] = words[i] + words[i+1]
words[i+1] = ""
}
}
words = util.AntiJoin(words, []string{""})
// find prn keyword
for i := len(words) - 1; i >= 0; i-- {
if strings.EqualFold(words[i], "prn") {
res.Flags = append(res.Flags, DirectionFlagPRN)
words = append(words[:i], words[i+1:]...)
break
}
}
freqIdx := len(words) - 1
if lastWord := strings.ToLower(words[len(words)-1]); lastWord == "bid" {
res.PeriodHours = 12
words = words[:len(words)-1]
} else if lastWord == "tid" {
res.PeriodHours = 8
words = words[:len(words)-1]
} else if lastWord == "qid" {
res.PeriodHours = 6
words = words[:len(words)-1]
} else {
for i := len(words) - 1; i >= 0; i-- {
if strings.HasPrefix(strings.ToLower(words[i]), "q") {
freqIdx = i
break
}
}
freqStr := strings.ToLower(strings.Join(words[freqIdx:], ""))[1:]
if freqStr == "am" {
res.Flags = append(res.Flags, DirectionFlagAM)
res.PeriodHours = 24
} else if freqStr == "hs" {
res.Flags = append(res.Flags, DirectionFlagHS)
res.PeriodHours = 24
} else {
if !(freqStr[0] >= '0' && freqStr[0] <= '9') {
freqStr = "1" + freqStr
}
freqRegexp := regexp.MustCompile(`^([0-9]+)([a-z]+)$`)
freqMatch := freqRegexp.FindStringSubmatch(freqStr)
if freqMatch == nil {
return nil, fmt.Errorf("invalid frequency: %s", freqStr)
}
freq, err := strconv.Atoi(freqMatch[1])
if err != nil {
return nil, fmt.Errorf("invalid frequency number : %s", freqMatch[1])
}
if freqMatch[2] == "d" {
res.PeriodHours = freq * 24
} else if freqMatch[2] == "h" {
res.PeriodHours = freq
} else {
return nil, fmt.Errorf("invalid frequency unit: %s", freqMatch[2])
}
}
}
words = words[:freqIdx]
dosageRegexp := regexp.MustCompile(`^([0-9]+)([a-z]*)$`)
var dosageMatch []string
if dosageMatch = dosageRegexp.FindStringSubmatch(words[len(words)-1]); dosageMatch == nil {
if dosageMatch = dosageRegexp.FindStringSubmatch(words[len(words)-2]); dosageMatch == nil {
return nil, fmt.Errorf("invalid dosage: %s", words[len(words)-2:])
} else {
res.DosageRoute = words[len(words)-1]
words = words[:len(words)-2]
}
} else {
words = words[:len(words)-1]
}
dosage, err := strconv.Atoi(dosageMatch[1])
if err != nil {
return nil, fmt.Errorf("invalid dosage number: %s", dosageMatch[1])
}
res.Dosage = dosage
res.DosageUnit = dosageMatch[2]
res.Name = strings.Join(words, " ")
s1, s2 := res.ShortHand()
res.DirectionShorthand = s1 + " " + s2
return res, nil
}
func (d Direction) KeyName() string {
return strings.ToLower(strings.SplitN(d.Name, " ", 2)[0])
}
func (d *Direction) ShortHand() (name string, direction string) {
builder := new(strings.Builder)
builder.WriteString(strconv.Itoa(d.Dosage))
if d.DosageUnit != "" {
builder.WriteString(" ")
builder.WriteString(d.DosageUnit)
}
if d.DosageRoute != "" {
builder.WriteString(" ")
builder.WriteString(d.DosageRoute)
}
if d.PeriodHours%24 == 0 {
qNd := d.PeriodHours / 24
qNdS := strconv.Itoa(qNd)
if qNd == 1 {
if util.Contain(d.Flags, DirectionFlagAM) {
qNdS = "AM"
} else if util.Contain(d.Flags, DirectionFlagHS) {
qNdS = "HS"
} else {
qNdS = "d"
}
} else {
qNdS += "d"
}
fmt.Fprintf(builder, " q%s", qNdS)
} else if d.PeriodHours == 12 {
builder.WriteString(" bid")
} else if d.PeriodHours == 8 {
builder.WriteString(" tid")
} else if d.PeriodHours == 6 {
builder.WriteString(" qid")
} else {
fmt.Fprintf(builder, " q%sh", strconv.Itoa(d.PeriodHours))
}
if util.Contain(d.Flags, DirectionFlagPRN) {
builder.WriteString(" PRN")
}
if d.OptSchedule != "" && d.OptSchedule != OptScheduleDefault {
if d.OptSchedule == OptScheduleWholeDose {
builder.WriteString(" sched(whole)")
}
}
return d.Name, builder.String()
}
type Dose struct {
Time time.Time
Dose int
}

View file

@ -0,0 +1,75 @@
package health
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestShortHandParser(t *testing.T) {
cases := [][]interface{}{
{"Atorvastatin 10mg QD", &Direction{
Name: "Atorvastatin",
PeriodHours: 24,
Dosage: 10,
DosageUnit: "mg",
Flags: make([]DirectionFlag, 0),
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"Atorvastatin 10mg TAB 10mg PO bid", &Direction{
Name: "Atorvastatin 10mg TAB",
PeriodHours: 12,
Dosage: 10,
DosageUnit: "mg",
DosageRoute: "PO",
Flags: make([]DirectionFlag, 0),
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"metformin 500mg qHS", &Direction{
Name: "metformin",
PeriodHours: 24,
Dosage: 500,
DosageUnit: "mg",
Flags: []DirectionFlag{DirectionFlagHS},
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"Amphetamine 10mg qam", &Direction{
Name: "Amphetamine",
PeriodHours: 24,
Dosage: 10,
DosageUnit: "mg",
Flags: []DirectionFlag{DirectionFlagAM},
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"Hydroxyzine 50mg qid prn sched(whole)", &Direction{
Name: "Hydroxyzine",
PeriodHours: 6,
Dosage: 50,
DosageUnit: "mg",
Flags: []DirectionFlag{DirectionFlagPRN},
OptSchedule: OptScheduleWholeDose,
Disclaimer: DirectionDisclaimer,
}}}
for _, c := range cases {
input, expected := c[0].(string), c[1].(*Direction)
actual, err := ParseShorthand(input)
if expected == nil {
assert.Error(t, err)
} else {
assert.NoError(t, err)
expected.DirectionShorthand = actual.DirectionShorthand
assert.Equal(t, expected, actual)
name, encoded := actual.ShortHand()
assert.Equal(t, expected.Name, name)
encodedDecoded, err := ParseShorthand(name + " " + encoded)
assert.NoError(t, err)
assert.Equal(t, expected, encodedDecoded)
}
}
}

View file

@ -0,0 +1,25 @@
package util
import "time"
type DateRange struct {
From time.Time
To time.Time
}
func NewDateRange(from time.Time, to time.Time) DateRange {
from, _ = time.Parse("2006-01-02", from.Format("2006-01-02"))
to, _ = time.Parse("2006-01-02", to.Format("2006-01-02"))
if from.After(to) {
from, to = to, from
}
return DateRange{from, to}
}
func (d DateRange) Days() int {
return int(d.To.Sub(d.From).Hours() / 24)
}
func DateRangeAround(date time.Time, months int) DateRange {
return NewDateRange(date.AddDate(0, -months, 0), date.AddDate(0, months, 0))
}

View file

@ -31,3 +31,11 @@ func AntiJoin[T comparable](a []T, b []T) []T {
}
return result
}
func Reverse[T any](a []T) []T {
var result []T
for i := len(a) - 1; i >= 0; i-- {
result = append(result, a[i])
}
return result
}

View file

@ -12,6 +12,7 @@ import (
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/eternal-flame-AD/yoake/internal/entertainment"
"github.com/eternal-flame-AD/yoake/internal/health"
"github.com/eternal-flame-AD/yoake/internal/servetpl"
"github.com/eternal-flame-AD/yoake/internal/session"
"github.com/eternal-flame-AD/yoake/internal/twilio"
@ -70,6 +71,7 @@ func Init(hostname string, comm *comm.Communicator, database db.DB) {
comm.RegisterAPIRoute(api.Group("/comm", logMiddleware("api_comm", nil)))
auth.Register(api.Group("/auth", logMiddleware("api_auth", nil)))
entertainment.Register(api.Group("/entertainment", logMiddleware("api_entertainment", nil)), database)
health.Register(api.Group("/health", logMiddleware("api_health", nil)), database, comm)
}
e.Use(

View file

@ -0,0 +1,472 @@
<h1 class="page-header">Med Compliance</h1>
<div class="contianer">
<div class="row p-2">
<div class="col">
<div class="card border">
<div class="card-header">
<h3 class="card-title">Directions</h3>
</div>
<div class="card-body">
<style>
.med-timeline {
height: 2.5rem;
flex-shrink: 3;
}
.med-title {
flex-grow: 1;
min-width: 40%;
}
</style>
<div class="accordion" id="med-directions-accordion">
<div class="accordion-item d-none" id="med-direction-accordion-tpl" data-weight="0">
<h2 class="accordion-header trima-procedure-hidden">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
style="flex-wrap: wrap">
<span class="accordion-icon">
{{template "/partials/sidebar-trima-procedure-logos.tpl.html" "1.25rem" }}
&nbsp;
</span>
<span class="med-title accordion-title">
<div class="fw-bold">$name</div>
<div>900mg PO qHS</div>
</span>
<svg id="timeline-tpl" class="med-timeline d-none" viewbox="0 0 300 20"
xmlns="http://www.w3.org/2000/svg">
<polygon class="arrow-flipped d-none" points="3,0 10,7 17,0" fill="" />
<rect class="bar" width="280" x="10" y="7" height="6" fill="#8A6BBE" />
<line class="line d-none" x1="10" y1="0" x2="10" y2="20" stroke="#373C38"
stroke-width="1" />
<polygon class="arrow d-none" points="3,20 10,14 17,20" fill="#58B2DC" />
</svg>
</button>
</h2>
<div class="accordion-collapse collapse show">
<div class="px-2 py-2 px-lg-5">
</div>
<div class="p-2">
<form class="med-take-form px-3">
<div class="row g-3 align-items-center">
<div class="col-auto">
<label for="dosage" class="form-label">Dosage Taken: </label>
</div>
<div class="col-auto">
<input type="number" class="form-control" id="dosage">
</div>
<div class="col-auto">
<input type="submit" class="btn btn-primary mt-2" value="Submit">
</div>
</div>
</form>
</div>
<div class="p-2">
<h5>History</h5>
<div class="p-2 table-responsive" style="height:20em;overflow-y:scroll;">
<table class="table table-striped compliance-log">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Dose</th>
<th scope="col">Offset</th>
<th scope="col">Offset (7 day)</th>
</tr>
</thead>
<tbody>
<tr class="placeholder">
<th class="placeholder">Loading...</th>
<td class="placeholder"></td>
<td class="placeholder"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row p-2">
<div class="col">
<div class="card border">
<div class="card-header">
<h3 class="card-title">Manage</h3>
</div>
<div class="card-body">
<form id="addMed" autocomplete="off">
<div class="mb-3">
<label id="med-shorthand-input" for="shorthand" class="form-label">Shorthand</label>
<input type="text" class="form-control" id="shorthand"
placeholder="Atorvastatin 10mg TAB 20mg PO qAM">
<label for="name" class="form-label">Name: </label>
<input type="text" class="form-control" id="name" placeholder="Atorvastatin 10mg TAB">
<label for="dosage" class="form-label">Dosage: </label>
<input type="number" class="form-control" id="dosage" placeholder="20">
<label for="dosage_unit" class="form-label">Dosage Unit: </label>
<input type="text" class="form-control" id="dosage_unit" placeholder="mg">
<label for="dosage_route" class="form-label">Dosage Route: </label>
<input type="text" class="form-control" id="dosage_route" placeholder="PO">
<label for="period_hours" class="form-label">Period (Hours): </label>
<input type="number" class="form-control" id="period_hours" placeholder="24">
</div>
<div class="mb-3">
<label for="flags" class="form-label">Flags:</label>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-qam" name="qAM" value="qam">
<label for="flags-qam" class="form-check-label">qAM</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-qhs" name="qHS" value="qhs">
<label for="flags-qhs" class="form-check-label">qHS</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-prn" name="PRN" value="prn">
<label for="flags-prn" class="form-check-label">PRN</label>
</div>
</div>
<div class="mb-3">
<label for="flags" class="form-label">Schedule:</label>
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" id="schedule-default" name="schedule"
value="default">
<label for="schedule-default" class="form-check-label">Default</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" id="schedule-whole" name="schedule"
value="whole">
<label for="schedule-whole" class="form-check-label">Whole Dose</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<script>
$("#addMed #shorthand").on('change', function () {
let shorthand = $(this).val();
$.ajax({
url: "/api/health/meds/shorthand/parse?shorthand=" + encodeURIComponent(shorthand),
success: function (data) {
$("#addMed #name").val(data.name);
$("#addMed #dosage").val(data.dosage);
$("#addMed #dosage_unit").val(data.dosage_unit);
$("#addMed #dosage_route").val(data.dosage_route);
$("#addMed #period_hours").val(data.period_hours);
$("#addMed #flags-qam").prop("checked", data.flags.includes("qam"));
$("#addMed #flags-qhs").prop("checked", data.flags.includes("qhs"));
$("#addMed #flags-prn").prop("checked", data.flags.includes("prn"));
$("#addMed #schedule-default").prop("checked", data.schedule == "default");
$("#addMed #schedule-whole").prop("checked", data.schedule == "whole");
}
})
});
$("#addMed").on("submit", function (e) {
e.preventDefault();
let name = $("#addMed #name").val();
let dosage = $("#addMed #dosage").val();
let dosage_unit = $("#addMed #dosage_unit").val();
let dosage_route = $("#addMed #dosage_route").val();
let period_hours = $("#addMed #period_hours").val();
let flags = [];
if ($("#addMed #flags-qam").prop("checked")) {
flags.push("qam");
}
if ($("#addMed #flags-qhs").prop("checked")) {
flags.push("qhs");
}
if ($("#addMed #flags-prn").prop("checked")) {
flags.push("prn");
}
let schedule = $("#addMed input[name=schedule]:checked").val();
$.ajax({
url: "/api/health/meds/directions",
method: "POST",
contentType: "application/json",
data: JSON.stringify({
name: name,
dosage: parseInt(dosage),
dosage_unit: dosage_unit,
dosage_route: dosage_route,
period_hours: parseInt(period_hours),
flags: flags,
schedule: schedule
}),
success: function (data) {
window.location.reload();
},
})
})
</script>
</div>
</div>
</div>
</div>
</div>
<script>
(() => {
"use strict";
const medKeyName = name =>
name.split(" ")[0].toLowerCase();
const dirAccEl = document.getElementById("med-directions-accordion")
const dirAccTpl = document.getElementById("med-direction-accordion-tpl")
let accByMeds = {};
const writeAccordion = async (medList, initial) => {
await Promise.all(
medList.map(med => new Promise((resolve, reject) => {
{
const prn = med.flags.includes("prn");
let id = "med-direction-accordion-" + med.name
let accEl = document.getElementById(id)
if (!accEl) {
accEl = dirAccTpl.cloneNode(true)
accEl.id = id
}
const medTakeForm = accEl.querySelector(".med-take-form")
medTakeForm.querySelector("input#dosage").value = med.dosage
const medTimeline = (() => {
let timeline = accEl.querySelector("#timeline")
const medTimelineTpl = accEl.querySelector("#timeline-tpl")
if (timeline) {
timeline.parentElement.removeChild(timeline)
}
timeline = medTimelineTpl.cloneNode(true)
medTimelineTpl.parentElement.append(timeline)
timeline.id = "timeline"
timeline.classList.remove("d-none")
return timeline
})()
medTakeForm.onsubmit = e => {
e.preventDefault();
let dosage = medTakeForm.querySelector("input#dosage").value
if (confirm("Really Submit?"))
$.ajax({
url: "/api/health/meds/compliance/log",
method: "POST",
contentType: "application/json",
data: JSON.stringify({
med_keyname: medKeyName(med.name),
actual: {
time: dayjs().format(),
dose: parseInt(dosage)
},
}),
success: function (data) {
window.location.reload();
},
})
}
accByMeds[medKeyName(med.name)] = accEl
accEl.id = "med-direction-accordion-" + med.name
let title = accEl.querySelector(".accordion-title")
title.querySelector("div:first-child").innerText = med.name
title.querySelector("div:last-child").innerText = med.shorthand
const bodyId = accEl.querySelector(".accordion-collapse").id = "med-direction-body-" + medKeyName(med.name)
accEl.querySelector(".accordion-button").setAttribute("data-bs-target", "#" + bodyId)
accEl.querySelector(".accordion-button").setAttribute("aria-controls", bodyId)
$.ajax({
url: "/api/health/meds/compliance/med/" + medKeyName(med.name) + "/project",
type: "GET",
dataType: "json",
error: function (xhr, status, error) {
reject(error)
},
success: function (data) {
let icon = accEl.querySelector(".accordion-icon")
let important = false
let available = false
icon.setAttribute("class", "accordion-icon")
if (data.dose_offset < -0.2 || dayjs().isAfter(dayjs(data.expected.time).add(1, "day"))) {
accEl.setAttribute("data-weight", prn ? 5 : 15)
icon.classList.add("trima-procedure-ineligible")
} else if (data.dose_offset >= 0 && !prn) {
available = true
accEl.setAttribute("data-weight", 50)
important = true
icon.classList.add("trima-procedure-optimal")
} else {
available = true
accEl.setAttribute("data-weight", prn ? 10 : 20)
icon.classList.add("trima-procedure-valid")
}
if (initial) {
accEl.querySelector(".accordion-collapse").classList[important ? "add" : "remove"]("show")
accEl.querySelector(".accordion-button").classList[important ? "remove" : "add"]("collapsed")
}
medTakeForm.querySelector("input#dosage").value = data.expected.dose
$.ajax({
url: "/api/health/meds/compliance/med/" + medKeyName(med.name) + "/log",
type: "GET",
dataType: "json",
error: function (xhr, status, error) {
reject(error)
},
success: function (logs) {
const tbody = accEl.querySelector(".compliance-log tbody");
tbody.innerHTML = "";
let projectedTr = document.createElement("tr");
projectedTr.classList.add("table-primary");
projectedTr.innerHTML = `<th scope="row"></th><td></td><td></td><td></td>`;
labelTimeElement(projectedTr.children[0], data.expected.time);
projectedTr.children[1].innerText = `${data.expected.dose} ${med.dosage_unit} (${prn ? "available" : "scheduled"})`;
tbody.appendChild(projectedTr);
logs.forEach(log => {
const tr = document.createElement("tr");
tr.innerHTML = `<th scope="row"></th><td></td><td></td><td></td>`;
labelTimeElement(tr.children[0], log.actual.time)
tr.children[1].innerText = `${log.actual.dose}/${log.expected.dose} ${med.dosage_unit}`;
if (log.actual.dose !== log.expected.dose) {
tr.children[1].classList.add("table-warning");
} else {
tr.children[1].classList.add("table-success");
}
tr.children[2].innerText = log.dose_offset.toFixed(2);
if (Math.abs(log.dose_offset) > 1) {
tr.children[2].classList.add("table-danger");
} else if (Math.abs(log.dose_offset) > 0.5) {
tr.children[2].classList.add("table-warning");
} else {
tr.children[2].classList.add("table-success");
}
// compute 7 day offset
const weekFrom = dayjs(log.actual.time).subtract(7, 'day')
let offset = 0
logs.forEach(log => {
if (dayjs(log.actual.time).isAfter(weekFrom)) {
offset += log.dose_offset
}
})
tr.children[3].innerText = offset.toFixed(2);
if (Math.abs(offset) > 1) {
tr.children[3].classList.add("table-danger");
} else if (Math.abs(offset) > 0.5) {
tr.children[3].classList.add("table-warning");
} else {
tr.children[3].classList.add("table-success");
}
tbody.appendChild(tr);
});
// compute timeline
const timelineStart = dayjs().subtract(med.period_hours * 3, 'hour')
const timelineEnd = dayjs().add(med.period_hours, 'hour')
let timelineDoses = [
{ "type": "now", "time": dayjs(), "dose": 0 },
]
if (!(prn && available)) {
timelineDoses.push({ "type": "projected", "time": data.expected.time, "dose": data.expected.dose })
}
logs.forEach(log => {
timelineDoses.push({ "type": "actual", "time": log.actual.time, "dose": log.actual.dose })
timelineDoses.push({ "type": "expected", "time": log.expected.time, "dose": log.expected.dose })
})
if (prn)
$(medTimeline).find(".bar").attr("fill", "#B19693");
timelineDoses = timelineDoses.map(dose => {
dose.time = dayjs(dose.time)
dose.timerel = dose.time.diff(timelineStart) / timelineEnd.diff(timelineStart)
return dose
}).filter(dose => {
return dose.time.isAfter(timelineStart) && dose.time.isBefore(timelineEnd)
}).forEach(dose => {
console.log(dose)
let baseClass = ""
let fill = ""
switch (dose.type) {
case "projected":
baseClass = "arrow-flipped"
fill = "#ECB88A"
break
case "actual":
baseClass = "arrow"
fill = "#7BA23F"
break
case "expected":
baseClass = "arrow-flipped"
fill = "#58B2DC"
break
case "now":
baseClass = "line"
fill = ""
break
}
let arrow = $("." + baseClass).clone()
if (fill)
arrow.attr("fill", fill)
arrow.removeClass("d-none")
const fullrange = 280
arrow.attr("transform", "translate(" + (dose.timerel * fullrange) + ", 0)")
$(medTimeline).append(arrow)
})
accEl.classList.remove("d-none")
let inserted = false
for (const node of dirAccEl.children) {
if (node.classList.contains("accordion-item"))
if (parseInt(node.getAttribute("data-weight")) <
parseInt(accEl.getAttribute("data-weight"))) {
dirAccEl.insertBefore(accEl, node)
inserted = true
break;
}
}
if (!inserted) {
dirAccEl.appendChild(accEl)
}
resolve()
}
})
}
})
}
}))
)
}
let updateTimer;
document.addEventListener("sidebar-activate", e => {
if (e.detail.page == "health-meds") {
$.ajax({
url: "/api/health/meds/directions",
type: "GET",
dataType: "json",
success: function (data) {
writeAccordion(data, true).then(() => {
updateTimer = setInterval(() => writeAccordion(data).catch(err => {
throw err
}), 300 * 1000)
})
}
})
} else {
clearInterval(updateTimer);
}
})
})()
</script>

View file

@ -63,7 +63,7 @@
<li class="nav-item">
<a class="nav-link" href="#entertainment-youtube" data-state-bind="entertainment-youtube">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" "2.5rem" }}
</span>Youtube Playlist
</a>
</li>
@ -81,12 +81,29 @@
<a class="nav-link" href="#canvas-grades" data-state-bind="canvas-grades"
data-auth-bind="admin">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" "2.5rem" }}
</span>Grades
</a>
</li>
</ul>
<h6
class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>Health</span>
<a class="link-secondary" href="#" aria-label="Health">
<span data-feather="plus-circle" class="align-text-bottom"></span>
</a>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item">
<a class="nav-link" href="#health-meds" data-state-bind="health-meds"
data-auth-bind="admin">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" "2.5rem" }}
</span>Med Compliance
</a>
</li>
</ul>
<h6
class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
@ -99,7 +116,7 @@
<li class="nav-item">
<a class="nav-link" href="#gotify-ui" data-state-bind="gotify-ui">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" "2.5rem" }}
</span>Gotify UI
</a>
</li>
@ -116,7 +133,7 @@
<li class="nav-item">
<a class="nav-link" href="#vault-ui" data-state-bind="vault-ui">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" "2.5rem" }}
</span>Vault UI
</a>
</li>
@ -157,6 +174,11 @@
{{ template "/includes/page-vault-ui.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-health-meds"
data-title="Med Compliance">
{{ template "/includes/page-health-meds.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-not-found" data-title="Not Found">
Not Found

View file

@ -1,4 +1,14 @@
function doNow(fn) {
fn();
return fn;
}
function labelTimeElement(tag, time) {
time = dayjs(time);
if (tag.innerText == "")
tag.innerText = time.fromNow();
tag.setAttribute("data-bs-toggle", "tooltip");
tag.setAttribute("data-bs-title", time.format("L LT"));
new bootstrap.Tooltip(tag);
}

View file

@ -1,3 +1,7 @@
{{ $size := 50 }}
{{ if . }}
{{ $size = .}}
{{ end }}
{{range $key, $name:= (parse_json `
{
"ineligible": "icon_t_vista_procedure_ineligible.gif",
@ -11,7 +15,8 @@
"icon_t_vista_procedure_valid.gif"
}
`)}}
<img aria-hidden="true" class="trima-procedure trima-procedure-{{$key}}" src={{ trima_img $name "url" }}>
<img aria-hidden="true" style="height:{{$size}};width:{{$size}};" class="trima-procedure trima-procedure-{{$key}}"
src={{ trima_img $name "url" }}>
{{ end }}